From fb231a0b7992b121d0e7b1b60c3d432e42eaeed2 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 30 Jan 2025 09:06:58 -0500 Subject: [PATCH 001/178] Initial content --- .coverage | Bin 0 -> 53248 bytes .github/workflows/ci.yml | 49 ++ .gitignore | 12 +- .python-version | 1 - CONTRIBUTING.md | 14 + LICENSE | 21 + README.md | 14 + pyproject.toml | 28 +- src/nexus_rpc/__init__.py | 2 - src/nexus_rpc/py.typed | 0 src/nexusrpc/__init__.py | 4 + src/nexusrpc/_service_definition.py | 170 ++++++ src/nexusrpc/handler/__init__.py | 93 ++++ src/nexusrpc/handler/_common.py | 195 +++++++ src/nexusrpc/handler/_core.py | 523 ++++++++++++++++++ src/nexusrpc/handler/_decorators.py | 360 ++++++++++++ src/nexusrpc/handler/_serializer.py | 99 ++++ src/nexusrpc/handler/_types.py | 26 + src/nexusrpc/handler/_util.py | 83 +++ src/nexusrpc/testing/client.py | 63 +++ tests/handler/test_forward_references.py | 22 + ...er_validates_service_handler_collection.py | 58 ++ ...collects_expected_operation_definitions.py | 212 +++++++ ..._service_handler_decorator_requirements.py | 212 +++++++ ...rrectly_functioning_operation_factories.py | 99 ++++ ..._decorator_selects_correct_service_name.py | 90 +++ ...ator_validates_against_service_contract.py | 152 +++++ ...tor_validates_duplicate_operation_names.py | 36 ++ ...corator_creates_valid_operation_handler.py | 63 +++ ..._creates_expected_operation_declaration.py | 45 ++ ..._decorator_selects_correct_service_name.py | 52 ++ .../test_service_decorator_validation.py | 43 ++ tests/test_get_input_and_output_types.py | 134 +++++ tests/test_util.py | 23 + uv.lock | 478 +++++++++++++++- 35 files changed, 3459 insertions(+), 17 deletions(-) create mode 100644 .coverage create mode 100644 .github/workflows/ci.yml delete mode 100644 .python-version create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE delete mode 100644 src/nexus_rpc/__init__.py delete mode 100644 src/nexus_rpc/py.typed create mode 100644 src/nexusrpc/__init__.py create mode 100644 src/nexusrpc/_service_definition.py create mode 100644 src/nexusrpc/handler/__init__.py create mode 100644 src/nexusrpc/handler/_common.py create mode 100644 src/nexusrpc/handler/_core.py create mode 100644 src/nexusrpc/handler/_decorators.py create mode 100644 src/nexusrpc/handler/_serializer.py create mode 100644 src/nexusrpc/handler/_types.py create mode 100644 src/nexusrpc/handler/_util.py create mode 100644 src/nexusrpc/testing/client.py create mode 100644 tests/handler/test_forward_references.py create mode 100644 tests/handler/test_handler_validates_service_handler_collection.py create mode 100644 tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py create mode 100644 tests/handler/test_service_handler_decorator_requirements.py create mode 100644 tests/handler/test_service_handler_decorator_results_in_correctly_functioning_operation_factories.py create mode 100644 tests/handler/test_service_handler_decorator_selects_correct_service_name.py create mode 100644 tests/handler/test_service_handler_decorator_validates_against_service_contract.py create mode 100644 tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py create mode 100644 tests/handler/test_sync_operation_handler_decorator_creates_valid_operation_handler.py create mode 100644 tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py create mode 100644 tests/service_definition/test_service_decorator_selects_correct_service_name.py create mode 100644 tests/service_definition/test_service_decorator_validation.py create mode 100644 tests/test_get_input_and_output_types.py create mode 100644 tests/test_util.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..ef808fad6035f43980215d869ae20308fdb763f9 GIT binary patch literal 53248 zcmeI4U2Ggz6~||GW_Nb%U0(+iw!wBiX+xbQwl}5^7*{|Iq$Q9hX&Wj5qU+h+yY|rC zna#{>Vx!V#Q=|X_sp1W(;*}Q!NFd;Whe*63rKRc%2%A{7Er#D}2afpg}=>viI) ziY+xw|5v+rX70zCbAI>Sd*|-#%%cwXMmZd0)N#7!U(_qk! zo*vLv-L${muBpsD_4%TBOey5Pt%%2qC&X;wx#B&;FBaP7H;2Dx*k+wF5I_I~KmY{Z z-2{3M7mU)red_tIhIXwbL*K4TKaPu^eB{W|sUy~@rB56?V#R6J!6A#n?5wq9`QBNp zBYmssw4~*@4X190j=N%ot1>O!4P=8SI!dFdjtkC3^?9%AG^kc6S4c$1ciOhUZapE_ zXQBeh*+RJ%#t9@uIx8-huns2G%a$*j(wA;s2C)_gokn@6{LIrsMrmwJy&xl-Y`;#Q z;Q?zXp>N93CJCy0euK5I`LCOZAfH>jBuPbxP_IoC_E_ABzKsdA*V+ilkf z`Io!x4mS?fh)*1P+tu8zZs;Y(zU8*LL0k6i9ca!JIk#wBtLr+ayK4G@SM9|;g2yHPW(SPmlztc3G7-FW^*eUBYF)28 zCzImS*<9(qz1u0yDpcn=&OP(ROr-LKeG&aO4f_1m22|#hzRI7K>$7L<7L6ty+wrN( z(ij}i02UW)*i_iAdEGGbUp|c7ljokkzOjJ*amt=bm80U+y-#{U;LxXi%JrsHqjczy z+Uq8rF&<~C0Zr;{J5@V5%nUfmmdw7toS*@hu68#23A5XTgAd~~@Zc7=U@fMQmP)R5 zhRVFaSH(4jJ_sNH0w4eaAOHd&00JNY0w4eaAOHfl9|29xs0Oe9GvbOO{zef32!H?x zfB*=900@8p2!H?xfB*=9z&n#bA)`%-^e-NVg{qC0l0N}_cy3|tLqlX$M!cqo*Tlcx znFc~@5C8!X009sH0T2KI5C8!X009sHfs{Z&n^en;-VscE?hA= za%tqLk;BD56u(YM2p|9gAOHd&00JNY0w4eaAOHdpfn%nw^d>8x3+O5JO2c+5fnTqL zvfc4~yXANlSFUw~*`V>nY-c@O_2T>}(eKnNtMnLrOZt^+H*{L`-1;MVl2+cavm+Xfj>Yj?3OtwO zozO_$-8+_7bzCQ`R!QK484@^lvjX|O81zb{kOiqb5mfDK#P|Qr;(dzvh493kBhQb_ z7r#-wJn}RpA%Fk~fB*=900@8p2!H?xfB*=@1Rm0Rlj_Z`<+iT>%}4cKdFRpw*8ln4 zkvXvbH+IMBWc{BzuG1^>cFZtc|7TC=z5P2Ex3T`$PwKt9cP_8L+SWd;_r`8o;HLF| z=0Or<-YmFH>wmoe55pMfoD5G5gi00@8p z2!H?xfB*=900@8p2!O!5ftC>R<#&%aFa4nTs-un?RP-+6{TG*(b4$t{872SG9Hl&&dqe%H@{<|k zy{}k5VubfAc}1lg@C(ZFKVDy;y}~(F(F%7g>&j?e*I&5wo?pH^mcOVC8Cj}6n*DQO zLZ_WYeX;SM7Y>eg9$0vG;)BMX{eSE%eCz776P-O8z2p|9gAOHd& z00JNY0w4eaAOHd&00OrT0sdt{HO**~&qo`>h&H)gw8>_pjjl%=4.12.2", +] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "mypy>=1.15.0", + "pyright>=1.1.400", + "pytest>=8.3.5", + "pytest-asyncio>=0.26.0", + "pytest-cov>=6.1.1", + "pytest-pretty>=1.3.0", + "ruff>=0.11.7", +] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/nexusrpc"] + +[tool.pyright] +include = ["src", "tests"] + +[tool.ruff] +target-version = "py39" + 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/nexus_rpc/py.typed b/src/nexus_rpc/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py new file mode 100644 index 0000000..2f6cc36 --- /dev/null +++ b/src/nexusrpc/__init__.py @@ -0,0 +1,4 @@ +from . import handler as handler +from ._service_definition import Operation as Operation +from ._service_definition import ServiceDefinition as ServiceDefinition +from ._service_definition import service as service diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py new file mode 100644 index 0000000..9100c18 --- /dev/null +++ b/src/nexusrpc/_service_definition.py @@ -0,0 +1,170 @@ +""" +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, + Optional, + Type, + Union, + overload, +) + +from .handler._types import ( + InputT, + OutputT, + ServiceDefinitionT, +) + + +# TODO(prerelease): support inheritance in service definitions +@dataclass +class ServiceDefinition: + name: str + operations: dict[str, Operation[Any, Any]] + + +@dataclass +class Operation(Generic[InputT, OutputT]): + """ + Used to define a Nexus operation in a Nexus service definition. + + To implement an operation handler, see `:py:meth:nexusrpc.handler.operation_handler`. + + Example: + + .. code-block:: python + + @nexusrpc.service + class MyNexusService: + my_operation: nexusrpc.Operation[MyInput, MyOutput] + """ + + name: Optional[str] = None + method_name: str = dataclasses.field(init=False) + input_type: Type[InputT] = dataclasses.field(init=False) + output_type: Type[OutputT] = dataclasses.field(init=False) + + @property + def key(self) -> str: + return self.name or self.method_name + + @classmethod + def _create( + cls, + *, + name: Optional[str] = None, + method_name: str, + input_type: Type, + output_type: Type, + ) -> Operation: + op = cls(name) + op.method_name = method_name + op.input_type = input_type + op.output_type = output_type + return op + + +@overload +def service(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: ... + + +@overload +def service( + *, name: Optional[str] = None +) -> Callable[[Type[ServiceDefinitionT]], Type[ServiceDefinitionT]]: ... + + +def service( + cls: Optional[Type[ServiceDefinitionT]] = None, + *, + name: Optional[str] = None, +) -> Union[ + Type[ServiceDefinitionT], + Callable[[Type[ServiceDefinitionT]], Type[ServiceDefinitionT]], +]: + """ + 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_operation: nexusrpc.Operation[MyInput, MyOutput] + """ + + def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: + if name is not None and not name: + raise ValueError("Service name must not be empty.") + service_name = name or cls.__name__ + # 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__ + + operations: dict[str, Operation] = {} + annotations: dict[str, Any] = getattr(cls, "__annotations__", {}) + for annot_name, op in annotations.items(): + if typing.get_origin(op) == Operation: + args = typing.get_args(op) + if len(args) != 2: + raise TypeError( + f"Each operation in the service definition should look like " + f"nexusrpc.Operation[MyInputType, MyOutputType]. " + f"However, '{annot_name}' in '{cls}' has {len(args)} type parameters." + ) + input_type, output_type = args + op = getattr(cls, annot_name, None) + if not op: + op = Operation._create( + method_name=annot_name, + input_type=input_type, + output_type=output_type, + ) + setattr(cls, annot_name, op) + else: + if not isinstance(op, Operation): + raise TypeError( + f"Operation {annot_name} must be an instance of nexusrpc.Operation, " + f"but it is a {type(op)}" + ) + op.method_name = annot_name + op.input_type = input_type + op.output_type = output_type + + if op.key in operations: + raise ValueError( + f"Operation {op.key} in service {service_name} is defined multiple times" + ) + operations[op.key] = op + + cls.__nexus_service__ = ServiceDefinition( # type: ignore + name=service_name, + operations=operations, + ) + + return cls + + if cls is None: + return decorator + else: + return decorator(cls) diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py new file mode 100644 index 0000000..dfe1833 --- /dev/null +++ b/src/nexusrpc/handler/__init__.py @@ -0,0 +1,93 @@ +# TODO(preview): show what it looks like to manually build a service implementation at runtime +# where the operations may be based on some runtime information. + +# TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" +# TODO(preview): pass mypy + + +from __future__ import annotations + +from ._common import ( + CancelOperationContext as CancelOperationContext, +) +from ._common import ( + FetchOperationInfoContext as FetchOperationInfoContext, +) +from ._common import ( + FetchOperationResultContext as FetchOperationResultContext, +) +from ._common import ( + HandlerError as HandlerError, +) +from ._common import ( + HandlerErrorType as HandlerErrorType, +) +from ._common import ( + Link as Link, +) +from ._common import ( + OperationContext as OperationContext, +) +from ._common import ( + OperationError as OperationError, +) +from ._common import ( + OperationErrorState as OperationErrorState, +) +from ._common import ( + OperationInfo as OperationInfo, +) +from ._common import ( + OperationState as OperationState, +) +from ._common import ( + StartOperationContext as StartOperationContext, +) +from ._common import ( + StartOperationResultAsync as StartOperationResultAsync, +) +from ._common import ( + StartOperationResultSync as StartOperationResultSync, +) +from ._core import ( + Handler as Handler, +) +from ._core import ( + OperationHandler as OperationHandler, +) +from ._core import ( + SyncOperationHandler as SyncOperationHandler, +) +from ._core import ( + UnknownOperationError as UnknownOperationError, +) +from ._core import ( + UnknownServiceError as UnknownServiceError, +) +from ._decorators import ( + operation_handler as operation_handler, +) +from ._decorators import ( + service_handler as service_handler, +) +from ._decorators import ( + sync_operation_handler as sync_operation_handler, +) +from ._serializer import ( + Content as Content, +) +from ._serializer import ( + LazyValue as LazyValue, +) +from ._serializer import ( + Serializer as Serializer, +) +from ._types import ( + MISSING_TYPE as MISSING_TYPE, +) +from ._util import ( + get_start_method_input_and_output_types_annotations as get_start_method_input_and_output_types_annotations, +) + +# TODO(prerelease): docstrings +# TODO(prerelease): check API docs diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py new file mode 100644 index 0000000..1b35fe7 --- /dev/null +++ b/src/nexusrpc/handler/_common.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import ( + Generic, + Optional, +) + +from ._types import OutputT + + +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" + UNAUTHENTICATED = "UNAUTHENTICATED" + UNAUTHORIZED = "UNAUTHORIZED" + NOT_FOUND = "NOT_FOUND" + RESOURCE_EXHAUSTED = "RESOURCE_EXHAUSTED" + INTERNAL = "INTERNAL" + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + UNAVAILABLE = "UNAVAILABLE" + UPSTREAM_TIMEOUT = "UPSTREAM_TIMEOUT" + + +class HandlerError(Exception): + """ + A Nexus handler error. + """ + + __cause__: BaseException + + def __init__( + self, + message: str, + *, + type: HandlerErrorType, + cause: BaseException, + # Whether this error should be considered retryable. If not specified, retry + # behavior is determined from the error type. For example, INTERNAL is retryable + # by default unless specified otherwise. + retryable: Optional[bool] = None, + ): + super().__init__(message) + self.type = type + self.__cause__ = cause + self.retryable = retryable + + +class OperationErrorState(Enum): + """ + The state of an operation as described by an OperationError. + """ + + FAILED = "failed" + CANCELED = "canceled" + + +class OperationError(Exception): + """ + An error that represents "failed" and "canceled" operation results. + """ + + def __init__(self, message: str, *, state: OperationErrorState): + super().__init__(message) + self.state = state + + +@dataclass +class Link: + """ + Link contains a URL and a Type that can be used to decode the URL. + Links can contain any arbitrary information as a percent-encoded URL. + It can be used to pass information about the caller to the handler, or vice versa. + """ + + # The URL must be percent-encoded. + url: str + # Can describe an actual data type for decoding the URL. Valid chars: alphanumeric, '_', '.', + # '/' + type: str + + +@dataclass +class OperationContext: + """Context for the execution of the requested operation method. + + Includes information from the request.""" + + # The name of the service that the operation belongs to. + service: str + # The name of the operation. + operation: str + # Optional header fields sent by the caller. + headers: dict[str, str] = field(default_factory=dict) + + +@dataclass +class StartOperationContext(OperationContext): + """Context for the start method. + + Includes information from the request.""" + + # 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_url: Optional[str] = None + + # Optional header fields set by the caller to be attached to the callback request when an + # asynchronous operation completes. + callback_header: dict[str, str] = field(default_factory=dict) + + # Request ID that may be used by the server handler to dedupe a start request. + # By default a v4 UUID will be generated by the client. + request_id: Optional[str] = None + + # 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. + inbound_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. + outbound_links: list[Link] = field(default_factory=list) + + +@dataclass +class CancelOperationContext(OperationContext): + """Context for the cancel method. + + Includes information from the request.""" + + +@dataclass +class FetchOperationInfoContext(OperationContext): + """Context for the fetch_info method. + + Includes information from the request.""" + + +@dataclass +class FetchOperationResultContext(OperationContext): + """Context for the fetch_result method. + + Includes information from the request.""" + + +class OperationState(Enum): + """ + Describes the current state of an operation. + """ + + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELED = "canceled" + RUNNING = "running" + + +@dataclass +class OperationInfo: + """ + Information about an operation. + """ + + # Token identifying the operation (returned on operation start). + token: str + + # The operation's status. + status: OperationState + + +# TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? +@dataclass +class StartOperationResultSync(Generic[OutputT]): + """ + A result returned synchronously by the start method of a nexus operation handler. + """ + + value: OutputT + + +# TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? +# TODO(prelease) Demonstrate a type-safe fetch_result +@dataclass +class StartOperationResultAsync: + """ + A value returned by the start method of a nexus operation handler indicating that the + operation is responding asynchronously. + """ + + token: str diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py new file mode 100644 index 0000000..4273351 --- /dev/null +++ b/src/nexusrpc/handler/_core.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +import asyncio +import inspect +import typing +import warnings +from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import ( + Any, + Awaitable, + Callable, + Generic, + Optional, + Sequence, + Type, + Union, +) + +from typing_extensions import Self + +import nexusrpc +import nexusrpc._service_definition +from nexusrpc.handler._util import is_async_callable + +from ._common import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + OperationInfo, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, +) +from ._serializer import LazyValue +from ._types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT + + +class UnknownServiceError(RuntimeError): + """Raised when a request contains a service name that does not match a service handler.""" + + pass + + +class UnknownOperationError(RuntimeError): + """Raised when a request contains an operation name that does not match an operation handler.""" + + pass + + +@dataclass +class Handler: + """ + 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. + """ + + service_handlers: dict[str, ServiceHandler] + + def __init__( + self, + user_service_handlers: Sequence[Any], + executor: Optional[SyncExecutor] = 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: An executor to run non-`async def` operation handlers in. + """ + self.executor = executor + self.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 self.service_handlers: + raise RuntimeError( + f"Service '{sh.service.name}' has already been registered." + ) + if self.executor is None: + for op_name, operation_handler in sh.operation_handlers.items(): + if not is_async_callable(operation_handler.start): + raise RuntimeError( + f"Service '{sh.service.name}' operation '{op_name}' start must be an `async def` if no executor is provided." + ) + if not is_async_callable(operation_handler.cancel): + raise RuntimeError( + f"Service '{sh.service.name}' operation '{op_name}' cancel must be an `async def` if no executor is provided." + ) + self.service_handlers[sh.service.name] = sh + + async def start_operation( + self, + ctx: StartOperationContext, + input: LazyValue, + ) -> 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] + input = await input.consume(as_type=op.input_type) + + if is_async_callable(op_handler.start): + # TODO(preview): apply middleware stack as composed awaitables + return await op_handler.start(ctx, input) + else: + # TODO(preview): apply middleware stack as composed functions + if not self.executor: + raise RuntimeError( + "Operation start handler method is async but " + "no executor was provided to the Handler constructor. " + ) + result = await self.executor.run_sync(op_handler.start, ctx, input) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation start handler method {op_handler.start} returned an " + "awaitable but is not an `async def` coroutine function." + ) + return result + + async 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. + """ + raise NotImplementedError + + async 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. + """ + raise NotImplementedError + + async 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. + """ + 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: + if not self.executor: + raise RuntimeError( + "Operation cancel handler method is async but " + "no executor was provided to the Handler constructor." + ) + result = await self.executor.run_sync(op_handler.cancel, ctx, token) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation start handler method {op_handler.cancel} returned an " + "awaitable but is not an `async def` coroutine function." + ) + return result + + 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: + # TODO(prerelease): can this raise HandlerError directly or is HandlerError always a + # wrapper? I have currently made its __cause__ required but if it's not a + # wrapper then that is wrong. + raise UnknownServiceError(f"No handler for service '{service_name}'.") + return service + + +@dataclass +class ServiceHandlerDefinition: + """Internal representation of a user's Nexus service implementation class. + + This class is not part of the public API. + """ + + service: nexusrpc.ServiceDefinition + operation_handler_factories: dict[str, Callable[[Any], OperationHandler[Any, Any]]] + + +@dataclass +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` or + :py:func:`@nexusrpc.handler.sync_operation_handler`. + + Instances of this class are created automatically from user service handler 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 programatically creating Nexus service implementations. + """ + + service: nexusrpc.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 = getattr(user_instance.__class__, "__nexus_service__", None) + if not isinstance(service, nexusrpc.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." + ) + op_handlers = { + name: factory(user_instance) + for name, factory in collect_operation_handler_factories( + user_instance.__class__, service + ).items() + } + 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: + msg = ( + f"Nexus service definition '{self.service.name}' has no operation '{operation}'. " + f"There are {len(self.service.operations)} operations in the definition." + ) + if self.service.operations: + msg += f": {', '.join(sorted(self.service.operations.keys()))}" + msg += "." + raise UnknownOperationError(msg) + operation_handler = self.operation_handlers.get(operation) + if operation_handler is None: + # This should not be possible. If a service definition was supplied then + # this was checked; if not then the definition was generated from the + # operation handlers. + msg = ( + f"Nexus service implementation '{self.service.name}' has no handler for operation '{operation}'. " + f"There are {len(self.operation_handlers)} available operation handlers" + ) + if self.operation_handlers: + msg += f": {', '.join(sorted(self.operation_handlers.keys()))}" + msg += "." + raise UnknownOperationError(msg) + return operation_handler + + +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. + + Alternatively, to create an operation handler that is limited to returning + synchronously, create the start method of the :py:class:`OperationHandler` on your + service handler class and apply the + :py:func:`@nexusrpc.handler.sync_operation_handler` decorator. + """ + + # TODO(preview): We are using `def` signatures with union return types in this abstract + # base class to represent both `def` and `async` def implementations in child classes. + # However, this causes VSCode to autocomplete the methods with non-sensical signatures + # such as + # + # async def fetch_result(self, ctx: FetchOperationResultContext, token: str) -> Output | asyncio.Awaitable[Output] + # + # Can we improve this DX? + + @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. + """ + + def start( + self, ctx: StartOperationContext, input: InputT + ) -> Union[ + StartOperationResultSync[OutputT], + Awaitable[StartOperationResultSync[OutputT]], + ]: + """ + Start the operation and return its final result synchronously. + + Note that this method may be either `async def` or `def`. The name + 'SyncOperationHandler' means that the operation responds synchronously according + to the Nexus protocol; it doesn't refer to whether or not the implementation of + the start method is an `async def` or `def`. + """ + raise NotImplementedError( + "Start method must be implemented by subclasses of SyncOperationHandler." + ) + + def fetch_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> Union[OperationInfo, Awaitable[OperationInfo]]: + raise NotImplementedError( + "Cannot fetch operation info for an operation that responded synchronously." + ) + + def fetch_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Union[OutputT, Awaitable[OutputT]]: + raise NotImplementedError( + "Cannot fetch the result of an operation that responded synchronously." + ) + + def cancel( + self, ctx: CancelOperationContext, token: str + ) -> Union[None, Awaitable[None]]: + raise NotImplementedError( + "An operation that responded synchronously cannot be cancelled." + ) + + +def collect_operation_handler_factories( + user_service_cls: Type[ServiceHandlerT], + service: Optional[nexusrpc.ServiceDefinition], +) -> dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]]: + """ + Collect operation handler methods from a user service handler class. + """ + factories = {} + op_method_names = ( + {op.method_name for op in service.operations.values()} if service else set() + ) + for name, method in inspect.getmembers(user_service_cls, inspect.isfunction): + op_defn = getattr(method, "__nexus_operation__", None) + if isinstance(op_defn, nexusrpc.Operation): + # This is a method decorated with one of the *operation_handler decorators + # assert op_defn.key == name + if op_defn.key in factories: + raise RuntimeError( + f"Operation '{op_defn.key}' in service '{user_service_cls.__name__}' " + f"is defined multiple times." + ) + if service and name not in op_method_names: + method_names = ", ".join(f"'{s}'" for s in sorted(op_method_names)) + raise TypeError( + f"Operation method name '{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: {method_names}." + ) + + factories[op_defn.key] = method + # Check for accidentally missing decorator on an OperationHandler factory + # TODO(preview): support disabling warning in @service_handler decorator? + elif ( + typing.get_origin(typing.get_type_hints(method).get("return")) + == OperationHandler + ): + warnings.warn( + f"Method '{name}' in class '{user_service_cls.__name__}' " + f"returns OperationHandler but has not been decorated. " + f"Did you forget to apply the @nexusrpc.handler.operation_handler decorator?", + UserWarning, + stacklevel=2, + ) + return factories + + +def validate_operation_handler_methods( + user_service_cls: Type[ServiceHandlerT], + user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], + service_definition: nexusrpc.ServiceDefinition, +) -> None: + """Validate operation handler methods against a service definition.""" + for op_name, op_defn in service_definition.operations.items(): + method = user_methods.get(op_name) + if not method: + raise TypeError( + f"Service '{user_service_cls}' does not implement operation '{op_name}' in interface '{service_definition}'. " + ) + op = getattr(method, "__nexus_operation__", None) + if not isinstance(op, nexusrpc.Operation): + raise RuntimeError( + 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` or " + f":py:func:`@nexusrpc.handler.sync_operation_handler`?" + ) + if op.input_type != op_defn.input_type and op.input_type != MISSING_TYPE: + raise TypeError( + f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " + f"which does not match the input type '{op_defn.input_type}' in interface '{service_definition}'." + ) + if op.output_type != op_defn.output_type and op.output_type != MISSING_TYPE: + raise TypeError( + f"Operation '{op_name}' in service '{user_service_cls}' has output type '{op.output_type}', " + f"which does not match the output type '{op_defn.output_type}' in interface '{service_definition}'." + ) + if service_definition.operations.keys() > user_methods.keys(): + raise TypeError( + f"Service '{user_service_cls}' does not implement all operations in interface '{service_definition}'. " + f"Missing operations: {service_definition.operations.keys() - user_methods.keys()}" + ) + if user_methods.keys() > service_definition.operations.keys(): + raise TypeError( + f"Service '{user_service_cls}' implements more operations than the interface '{service_definition}'. " + f"Extra operations: {user_methods.keys() - service_definition.operations.keys()}" + ) + + +def service_from_operation_handler_methods( + service_name: str, + user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], +) -> nexusrpc.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. + """ + operations: dict[str, nexusrpc.Operation[Any, Any]] = {} + for name, method in user_methods.items(): + op = getattr(method, "__nexus_operation__", None) + if not isinstance(op, nexusrpc.Operation): + raise RuntimeError( + 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` or " + f":py:func:`@nexusrpc.handler.sync_operation_handler`?" + ) + operations[op.name or op.method_name] = op + + return nexusrpc.ServiceDefinition(name=service_name, operations=operations) + + +class SyncExecutor: + """ + Run a synchronous function asynchronously. + """ + + def __init__(self, executor: ThreadPoolExecutor): + self._executor = executor + + def run_sync(self, fn: Callable[..., Any], *args: Any) -> Awaitable[Any]: + return asyncio.get_event_loop().run_in_executor(self._executor, fn, *args) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py new file mode 100644 index 0000000..a78d3ce --- /dev/null +++ b/src/nexusrpc/handler/_decorators.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import types +import typing +import warnings +from functools import wraps +from typing import ( + Any, + Awaitable, + Callable, + Optional, + Type, + TypeVar, + Union, + cast, + overload, +) + +import nexusrpc + +from ._common import ( + StartOperationContext, + StartOperationResultSync, +) +from ._core import ( + OperationHandler, + SyncOperationHandler, + collect_operation_handler_factories, + service_from_operation_handler_methods, + validate_operation_handler_methods, +) +from ._types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT +from ._util import ( + get_start_method_input_and_output_types_annotations, + is_async_callable, +) + + +@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` + or :py:func:`@nexusrpc.handler.sync_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 + + @nexusrpc.handler.service_handler class MyServiceHandler: + ... + + .. code-block:: python + + @nexusrpc.handler.service_handler(service=MyService) class MyServiceHandler: + ... + + .. code-block:: python + + @nexusrpc.handler.service_handler(name="my-service") class MyServiceHandler: + ... + """ + 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 = getattr(service, "__nexus_service__", None) + if not _service: + raise ValueError( + f"{service} is not a valid Nexus service definition. " + f"Use the @nexusrpc.service decorator on your 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.") + + op_factories = collect_operation_handler_factories(cls, _service) + service = _service or service_from_operation_handler_methods( + _name, op_factories + ) + validate_operation_handler_methods(cls, op_factories, service) + cls.__nexus_service__ = service # type: ignore + 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. + + Examples: + .. code-block:: python + + @nexusrpc.handler.operation_handler + def my_operation(self) -> Operation[MyInput, MyOutput]: + ... + + .. code-block:: python + + @nexusrpc.handler.operation_handler(name="my-operation") + defmy_operation(self) -> Operation[MyInput, MyOutput]: + ... + """ + + def decorator( + method: OperationHandlerFactoryT, + ) -> OperationHandlerFactoryT: + # Extract input and output types from the return type annotation + input_type = MISSING_TYPE + output_type = MISSING_TYPE + + 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}" + ) + + method.__nexus_operation__ = nexusrpc.Operation._create( + name=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_handler( + start_method: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ], +) -> Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]]: ... + + +@overload +def sync_operation_handler( + *, + name: Optional[str] = None, +) -> Callable[ + [ + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ] + ], + Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]], +]: ... + + +# TODO(preview): how do we help users that accidentally use @sync_operation_handler on a function that +# returns nexusrpc.handler.Operation[Input, Output]? +def sync_operation_handler( + start_method: Optional[ + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ] + ] = None, + *, + name: Optional[str] = None, +) -> Union[ + Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]], + Callable[ + [ + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ] + ], + Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]], + ], +]: + """Decorator marking a start method as a synchronous operation handler. + + Apply this decorator to a start method to convert it into an operation handler + factory method. + + Args: + start_method: The start method to decorate. + name: Optional name for the operation. If not provided, the method name will be used. + + Examples: + .. code-block:: python + + @nexusrpc.handler.sync_operation_handler + def my_operation(self, ctx: StartOperationContext, input: InputT) -> OutputT: + ... + """ + + def decorator( + start_method: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ], + ) -> Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]]: + def factory(service: ServiceHandlerT) -> OperationHandler[InputT, OutputT]: + op = SyncOperationHandler[InputT, OutputT]() + + # Non-async functions returning Awaitable are not supported + if is_async_callable(start_method): + start_method_async = cast( + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Awaitable[OutputT], + ], + start_method, + ) + + @wraps(start_method) + async def start_async( + _, ctx: StartOperationContext, input: InputT + ) -> StartOperationResultSync[OutputT]: + result = await start_method_async(service, ctx, input) + return StartOperationResultSync(result) + + op.start = types.MethodType(start_async, op) + + async def cancel_async(_, ctx: StartOperationContext, token: str): + raise NotImplementedError( + "An operation that responded synchronously cannot be cancelled." + ) + + op.cancel = types.MethodType(cancel_async, op) + + else: + start_method_sync = cast( + Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT], + start_method, + ) + + @wraps(start_method) + def start( + _, ctx: StartOperationContext, input: InputT + ) -> StartOperationResultSync[OutputT]: + result = start_method_sync(service, ctx, input) + return StartOperationResultSync(result) + + op.start = types.MethodType(start, op) + + def cancel(_, ctx: StartOperationContext, token: str): + raise NotImplementedError( + "An operation that responded synchronously cannot be cancelled." + ) + + op.cancel = types.MethodType(cancel, op) + return op + + input_type, output_type = get_start_method_input_and_output_types_annotations( + start_method + ) + method_name = getattr(start_method, "__name__", None) + if not method_name and callable(start_method): + method_name = start_method.__class__.__name__ + if not method_name: + raise TypeError( + f"Could not determine operation method name: " + f"expected {start_method} to be a function or callable instance." + ) + + factory.__nexus_operation__ = nexusrpc.Operation._create( + name=name, + method_name=method_name, + input_type=input_type, + output_type=output_type, + ) + + return factory + + if start_method is None: + return decorator + + return decorator(start_method) diff --git a/src/nexusrpc/handler/_serializer.py b/src/nexusrpc/handler/_serializer.py new file mode 100644 index 0000000..486a98d --- /dev/null +++ b/src/nexusrpc/handler/_serializer.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import ( + Any, + AsyncIterable, + Mapping, + Optional, + Protocol, + Type, +) + + +@dataclass +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: Optional[bytes] = None + """Request or response data. May be undefined for empty data.""" + + +class Serializer(Protocol): + """ + Serializer is used by the framework to serialize/deserialize input and output. + """ + + # TODO(preview): support non-async def + + async def serialize(self, value: Any) -> Content: + """Serialize encodes a value into a Content.""" + ... + + # TODO(prerelease): does None work as the sentinel type here, meaning do not attempt + # type conversion, despite the fact that Python treats None as a valid type? + async def deserialize( + self, content: Content, as_type: Optional[Type[Any]] = None + ) -> 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 LazyValue: + """ + 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. + """ + + 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: AsyncIterable 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. + """ + # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? + if self.stream is None: + return await self.serializer.deserialize( + Content(headers=self.headers), as_type=as_type + ) + + 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/handler/_types.py b/src/nexusrpc/handler/_types.py new file mode 100644 index 0000000..5a0ce1c --- /dev/null +++ b/src/nexusrpc/handler/_types.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TypeVar + +# Operation input type +InputT = TypeVar("InputT", contravariant=True) + +# Operation output type +OutputT = TypeVar("OutputT", covariant=True) + +# A user's service handler class, typically decorated with @service_handler +ServiceHandlerT = TypeVar("ServiceHandlerT") + +# A user's service definition class, typically decorated with @service +ServiceDefinitionT = TypeVar("ServiceDefinitionT") + + +class MISSING_TYPE: + """ + A missing input or output type. + + A sentinel type used to indicate an input or output type that is not specified by an + operation. + """ + + pass diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py new file mode 100644 index 0000000..e00b139 --- /dev/null +++ b/src/nexusrpc/handler/_util.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import functools +import inspect +import typing +import warnings +from typing import ( + Any, + Awaitable, + Callable, + Type, + Union, +) + +from typing_extensions import TypeGuard + +from ._common import StartOperationContext +from ._types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT + + +def get_start_method_input_and_output_types_annotations( + start_method: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ], +) -> tuple[ + Union[Type[InputT], Type[MISSING_TYPE]], + Union[Type[OutputT], Type[MISSING_TYPE]], +]: + """Return operation input and output types. + + `start_method` must be a type-annotated start method that returns a synchronous result. + """ + try: + type_annotations = typing.get_type_hints(start_method) + except TypeError: + # TODO(preview): stacklevel + warnings.warn( + f"Expected decorated start method {start_method} to have type annotations" + ) + return MISSING_TYPE, MISSING_TYPE + output_type = type_annotations.pop("return", MISSING_TYPE) + + if len(type_annotations) != 2: + # TODO(preview): stacklevel + warnings.warn( + f"Expected decorated start method {start_method} to have exactly two " + f"type-annotated parameters (ctx and input), but has {len(type_annotations)}: " + f"{type_annotations}." + ) + input_type = MISSING_TYPE + 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_method} to be an instance of " + f"StartOperationContext, but is {ctx_type}." + ) + input_type = MISSING_TYPE + + return input_type, output_type + + +# Copied from https://github.com/modelcontextprotocol/python-sdk +# +# Copyright (c) 2024 Anthropic, PBC. +# +# Modified to use TypeIs. +# +# 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)) + ) diff --git a/src/nexusrpc/testing/client.py b/src/nexusrpc/testing/client.py new file mode 100644 index 0000000..bdb8af4 --- /dev/null +++ b/src/nexusrpc/testing/client.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from typing import Any + +import httpx + + +@dataclass +class ServiceClient: + server_address: str # E.g. http://127.0.0.1:7243 + endpoint: str + service: str + + async def start_operation( + self, + operation: str, + body: dict[str, Any], + headers: dict[str, str] = {}, + ) -> httpx.Response: + """ + Start a Nexus operation. + """ + async with httpx.AsyncClient() as http_client: + return await http_client.post( + f"{self.server_address}/nexus/endpoints/{self.endpoint}/services/{self.service}/{operation}", + json=body, + headers=headers, + ) + + async def fetch_operation_info( + self, + operation: str, + token: str, + ) -> httpx.Response: + async with httpx.AsyncClient() as http_client: + return await http_client.get( + f"{self.server_address}/nexus/endpoints/{self.endpoint}/services/{self.service}/{operation}", + # Token can also be sent as "Nexus-Operation-Token" header + params={"token": token}, + ) + + async def fetch_operation_result( + self, + operation: str, + token: str, + ) -> httpx.Response: + async with httpx.AsyncClient() as http_client: + return await http_client.get( + f"{self.server_address}/nexus/endpoints/{self.endpoint}/services/{self.service}/{operation}/result", + # Token can also be sent as "Nexus-Operation-Token" header + params={"token": token}, + ) + + async def cancel_operation( + self, + operation: str, + token: str, + ) -> httpx.Response: + async with httpx.AsyncClient() as http_client: + return await http_client.post( + f"{self.server_address}/nexus/endpoints/{self.endpoint}/services/{self.service}/{operation}/cancel", + # Token can also be sent as "Nexus-Operation-Token" header + params={"token": token}, + ) diff --git a/tests/handler/test_forward_references.py b/tests/handler/test_forward_references.py new file mode 100644 index 0000000..5e574dc --- /dev/null +++ b/tests/handler/test_forward_references.py @@ -0,0 +1,22 @@ +# TODO(prerelease) This test fails with this import line +from __future__ import annotations + +import pytest + +import nexusrpc +import nexusrpc.handler + + +@nexusrpc.service +class ContractA: + base_op: nexusrpc.Operation[int, str] + + +@pytest.mark.skip( + reason="TODO(prerelease): The service contract decorator does not support forward type reference" +) +def test_service_definition_decorator_collects_operations_from_annotations(): + user_service_defn_cls = nexusrpc.service(ContractA) + service_defn = getattr(user_service_defn_cls, "__nexus_service__", None) + assert isinstance(service_defn, nexusrpc.ServiceDefinition) + assert service_defn.operations.keys() == {"base_op"} 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..f1c3473 --- /dev/null +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -0,0 +1,58 @@ +""" +Test that the Handler constructor processes the supplied collection of service handlers +correctly. +""" + +import pytest + +import nexusrpc +from nexusrpc.handler import Handler + + +def test_service_must_use_decorator(): + class Service: + pass + + with pytest.raises(RuntimeError): + Handler([Service()]) + + +def test_services_are_collected(): + class OpHandler(nexusrpc.handler.SyncOperationHandler[int, int]): + async def start( + self, + ctx: nexusrpc.handler.StartOperationContext, + input: int, + ) -> nexusrpc.handler.StartOperationResultSync[int]: ... + + async def cancel( + self, + ctx: nexusrpc.handler.CancelOperationContext, + token: str, + ) -> None: ... + + @nexusrpc.handler.service_handler + class Service1: + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.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(): + @nexusrpc.handler.service_handler(name="a") + class Service1: + pass + + @nexusrpc.handler.service_handler(name="a") + class Service2: + pass + + with pytest.raises(RuntimeError): + Handler([Service1(), Service2()]) 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..fb59f94 --- /dev/null +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -0,0 +1,212 @@ +""" +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._service_definition +import nexusrpc.handler + + +@dataclass +class Input: + pass + + +@dataclass +class Output: + pass + + +@dataclass +class _TestCase: + Service: Type[Any] + expected_operations: dict[str, nexusrpc.Operation] + Contract: Optional[Type[nexusrpc.ServiceDefinition]] = None + + +class ManualOperationHandler(_TestCase): + @nexusrpc.handler.service_handler + class Service: + @nexusrpc.handler.operation_handler + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + + expected_operations = { + "operation": nexusrpc.Operation._create( + method_name="operation", + input_type=Input, + output_type=Output, + ), + } + + +class ManualOperationHandlerWithNameOverride(_TestCase): + @nexusrpc.handler.service_handler + class Service: + @nexusrpc.handler.operation_handler(name="operation-name") + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + + expected_operations = { + "operation": nexusrpc.Operation._create( + name="operation-name", + method_name="operation", + input_type=Input, + output_type=Output, + ), + } + + +class SyncOperation(_TestCase): + @nexusrpc.handler.service_handler + class Service: + @nexusrpc.handler.sync_operation_handler + def sync_operation_handler( + self, ctx: nexusrpc.handler.StartOperationContext, input: Input + ) -> Output: ... + + expected_operations = { + "sync_operation_handler": nexusrpc.Operation._create( + method_name="sync_operation_handler", + input_type=Input, + output_type=Output, + ), + } + + +class SyncOperationWithOperationHandlerNameOverride(_TestCase): + @nexusrpc.handler.service_handler + class Service: + @nexusrpc.handler.sync_operation_handler(name="sync-operation-name") + async def sync_operation_handler( + self, ctx: nexusrpc.handler.StartOperationContext, input: Input + ) -> Output: ... + + expected_operations = { + "sync_operation_handler": nexusrpc.Operation._create( + 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] + + @nexusrpc.handler.service_handler(service=Contract) + class Service: + @nexusrpc.handler.operation_handler + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + + expected_operations = { + "operation": nexusrpc.Operation._create( + 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", + ) + + @nexusrpc.handler.service_handler(service=Contract) + class Service: + @nexusrpc.handler.operation_handler(name="operation-override") + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + + expected_operations = { + "operation": nexusrpc.Operation._create( + name="operation-override", + method_name="operation", + input_type=Input, + output_type=Output, + ), + } + + +class SyncOperationWithCallableInstance(_TestCase): + @nexusrpc.service + class Contract: + sync_operation_with_callable_instance: nexusrpc.Operation[Input, Output] + + @nexusrpc.handler.service_handler(service=Contract) + class Service: + class CallableInstanceStartMethod: + def __call__( + self, + _handler: Any, + ctx: nexusrpc.handler.StartOperationContext, + input: Input, + ) -> Output: ... + + sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( + name="sync_operation_with_callable_instance", + )(CallableInstanceStartMethod()) + + expected_operations = { + "sync_operation_with_callable_instance": nexusrpc.Operation._create( + 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, + # TODO(prerelease): make callable instances work. Input type is not inferred due to + # signature differing from normal mathod. See also + # SyncHandlerHappyPathWithNonAsyncCallableInstance in temporal tests. + # SyncOperationWithCallableInstance, + ], +) +@pytest.mark.asyncio +async def test_collected_operation_definitions( + test_case: Type[_TestCase], +): + service: nexusrpc.ServiceDefinition = getattr( + test_case.Service, "__nexus_service__" + ) + assert isinstance(service, nexusrpc.ServiceDefinition) + assert ( + service.name == "Service" + if test_case.Contract is None + else test_case.Contract.__nexus_service__.name # type: ignore + ) + for method_name, expected_op in test_case.expected_operations.items(): + actual_op = getattr(test_case.Service, method_name).__nexus_operation__ + 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 + + +def test_operation_without_decorator(): + class Service: + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + + with pytest.warns( + UserWarning, + match=r"Did you forget to apply the @nexusrpc.handler.operation_handler decorator\?", + ): + nexusrpc.handler.service_handler(Service) 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..6feb5e2 --- /dev/null +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -0,0 +1,212 @@ +# TODO(prerelease): The service definition decorator does not support forward type reference +# from __future__ import annotations + +from typing import Any, Type + +import pytest + +import nexusrpc +import nexusrpc._service_definition +import nexusrpc.handler +from nexusrpc.handler._core import ServiceHandler + +# TODO(prerelease): check return type of op methods including fetch_result and fetch_info +# temporalio.common._type_hints_from_func(hello_nexus.hello2().fetch_result), + + +# Test Case for Decorator Validation +class _DecoratorValidationTestCase: + UserServiceDefinition: Type[Any] + UserServiceHandler: Type[Any] + expected_error_message_pattern: str + + +class MissingOperationFromDefinition(_DecoratorValidationTestCase): + @nexusrpc.service + class ServiceDefinition: + op_A: nexusrpc.Operation[int, str] + op_B: nexusrpc.Operation[bool, float] + + UserServiceDefinition = ServiceDefinition + + class HandlerMissingOpB: + @nexusrpc.handler.operation_handler + def op_A(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + + # op_B is missing + + UserServiceHandler = HandlerMissingOpB + expected_error_message_pattern = r"does not implement operation 'op_B'" + + +class MethodNameDoesNotMatchDefinition(_DecoratorValidationTestCase): + @nexusrpc.service + class ServiceDefinition: + op_A: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="foo") + + UserServiceDefinition = ServiceDefinition + + class UserServiceHandler: + @nexusrpc.handler.operation_handler + def op_A_incorrect_method_name( + self, + ) -> nexusrpc.handler.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): + nexusrpc.handler.service_handler(service=test_case.UserServiceDefinition)( + test_case.UserServiceHandler + ) + + +# Test Cases for Service Implementation Inheritance +class _ServiceImplInheritanceTestCase: + test_case_name: str + BaseImpl: Type[Any] + ChildImpl: Type[Any] + expected_operations_in_child_handler: set[str] + + +class ServiceImplInheritanceWithDefinition(_ServiceImplInheritanceTestCase): + test_case_name = "ServiceImplInheritanceWithContracts" + + @nexusrpc.service + class ContractA: + base_op: nexusrpc.Operation[int, str] + + @nexusrpc.service + class ContractB: + base_op: nexusrpc.Operation[int, str] + child_op: nexusrpc.Operation[bool, float] + + @nexusrpc.handler.service_handler(service=ContractA) + class AImplementation: + @nexusrpc.handler.operation_handler + def base_op(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + + @nexusrpc.handler.service_handler(service=ContractB) + class BImplementation(AImplementation): + @nexusrpc.handler.operation_handler + def child_op(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... + + BaseImpl = AImplementation + ChildImpl = BImplementation + expected_operations_in_child_handler = {"base_op", "child_op"} + + +class ServiceImplInheritanceWithoutDefinition(_ServiceImplInheritanceTestCase): + test_case_name = "ServiceImplInheritanceWithoutDefinition" + + @nexusrpc.handler.service_handler + class BaseImplWithoutDefinition: + @nexusrpc.handler.operation_handler + def base_op_nc(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + + @nexusrpc.handler.service_handler + class ChildImplWithoutDefinition(BaseImplWithoutDefinition): + @nexusrpc.handler.operation_handler + def child_op_nc(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... + + BaseImpl = BaseImplWithoutDefinition + ChildImpl = ChildImplWithoutDefinition + expected_operations_in_child_handler = {"base_op_nc", "child_op_nc"} + + +@pytest.mark.parametrize( + "test_case", + [ + ServiceImplInheritanceWithDefinition, + ServiceImplInheritanceWithoutDefinition, + ], +) +def test_service_implementation_inheritance(test_case: _ServiceImplInheritanceTestCase): + child_instance = test_case.ChildImpl() + service_handler_meta = ServiceHandler.from_user_instance(child_instance) + + assert ( + set(service_handler_meta.operation_handlers.keys()) + == test_case.expected_operations_in_child_handler + ) + assert ( + set(service_handler_meta.service.operations.keys()) + == test_case.expected_operations_in_child_handler + ) + + +# Test Cases for Service Definition Inheritance (Current Behavior due to TODO) +class _ServiceDefinitionInheritanceTestCase: + test_case_name: str + ChildDefinitionInheriting: Type[Any] # We only need to inspect the child definition + expected_ops_in_child_definition: set[str] + + +class ServiceDefinitionInheritance(_ServiceDefinitionInheritanceTestCase): + test_case_name = "ServiceDefinitionInheritance" + + @nexusrpc.service + class BaseDef: + op_from_base_definition: nexusrpc.Operation[int, str] + + @nexusrpc.service + class ChildDefInherits(BaseDef): + op_from_child_definition: nexusrpc.Operation[bool, float] + + ChildDefinitionInheriting = ChildDefInherits + expected_ops_in_child_definition = { + "op_from_base_definition", + "op_from_child_definition", + } + + +@pytest.mark.parametrize( + "test_case", + [ + ServiceDefinitionInheritance, + ], +) +@pytest.mark.skip( + reason="TODO(prerelease): service definition inheritance is not supported yet" +) +def test_service_definition_inheritance_behavior( + test_case: _ServiceDefinitionInheritanceTestCase, +): + child_service_definition = getattr( + test_case.ChildDefinitionInheriting, "__nexus_service__", None + ) + + assert child_service_definition is not None, ( + f"{test_case.ChildDefinitionInheriting.__name__} lacks __nexus_service__ attribute." + ) + assert isinstance(child_service_definition, nexusrpc.ServiceDefinition), ( + "__nexus_service__ is not a nexusrpc.ServiceDefinition instance." + ) + + assert ( + set(child_service_definition.operations.keys()) + == test_case.expected_ops_in_child_definition + ) + + with pytest.raises( + TypeError, match="does not implement operation 'op_from_base_definition'" + ): + + @nexusrpc.handler.service_handler(service=test_case.ChildDefinitionInheriting) + class HandlerMissingChildOp: + @nexusrpc.handler.operation_handler + def op_from_base_definition( + self, + ) -> nexusrpc.handler.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..a59185d --- /dev/null +++ b/tests/handler/test_service_handler_decorator_results_in_correctly_functioning_operation_factories.py @@ -0,0 +1,99 @@ +""" +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._service_definition +import nexusrpc.handler +from nexusrpc.handler._core import collect_operation_handler_factories +from nexusrpc.handler._types import ( + InputT, + OutputT, +) +from nexusrpc.handler._util import is_async_callable + + +@dataclass +class _TestCase: + Service: Type[Any] + expected_operation_factories: dict[str, Any] + + +class ManualOperationDefinition(_TestCase): + @nexusrpc.handler.service_handler + class Service: + @nexusrpc.handler.operation_handler + def operation(self) -> nexusrpc.handler.OperationHandler[int, int]: + class OpHandler(nexusrpc.handler.SyncOperationHandler[int, int]): + async def start( + self, ctx: nexusrpc.handler.StartOperationContext, input: int + ) -> nexusrpc.handler.StartOperationResultSync[int]: + return nexusrpc.handler.StartOperationResultSync(7) + + return OpHandler() + + expected_operation_factories = {"operation": 7} + + +class SyncOperation(_TestCase): + @nexusrpc.handler.service_handler + class Service: + @nexusrpc.handler.sync_operation_handler + def sync_operation_handler( + self, ctx: nexusrpc.handler.StartOperationContext, input: int + ) -> int: + return 7 + + expected_operation_factories = {"sync_operation_handler": 7} + + +@pytest.mark.parametrize( + "test_case", + [ + ManualOperationDefinition, + SyncOperation, + ], +) +@pytest.mark.asyncio +async def test_collected_operation_factories_match_service_definition( + test_case: Type[_TestCase], +): + service: nexusrpc.ServiceDefinition = getattr( + test_case.Service, "__nexus_service__" + ) + assert isinstance(service, nexusrpc.ServiceDefinition) + assert service.name == "Service" + operation_factories = collect_operation_handler_factories( + test_case.Service, service + ) + assert operation_factories.keys() == test_case.expected_operation_factories.keys() + ctx = nexusrpc.handler.StartOperationContext( + service="Service", + operation="operation", + ) + + async def execute( + op: nexusrpc.handler.OperationHandler[InputT, OutputT], + ctx: nexusrpc.handler.StartOperationContext, + input: InputT, + ) -> Union[ + nexusrpc.handler.StartOperationResultSync[OutputT], + nexusrpc.handler.StartOperationResultAsync, + ]: + if is_async_callable(op.start): + return await op.start(ctx, input) + else: + return cast( + nexusrpc.handler.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, nexusrpc.handler.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..59050e5 --- /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 +import nexusrpc.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): + @nexusrpc.handler.service_handler + class ServiceImpl: + pass + + expected_name = "ServiceImpl" + + +class CalledWithoutArgs(_NameOverrideTestCase): + @nexusrpc.handler.service_handler() + class ServiceImpl: + pass + + expected_name = "ServiceImpl" + + +class CalledWithNameArg(_NameOverrideTestCase): + @nexusrpc.handler.service_handler(name="my-service-impl-🌈") + class ServiceImpl: + pass + + expected_name = "my-service-impl-🌈" + + +class CalledWithInterface(_NameOverrideTestCase): + @nexusrpc.handler.service_handler(service=ServiceInterface) + class ServiceImpl: + pass + + expected_name = "ServiceInterface" + + +class CalledWithInterfaceWithNameOverride(_NameOverrideTestCase): + @nexusrpc.handler.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 = getattr(test_case.ServiceImpl, "__nexus_service__") + assert isinstance(service, nexusrpc.ServiceDefinition) + assert service.name == test_case.expected_name + + +def test_name_must_not_be_empty(): + with pytest.raises(ValueError): + nexusrpc.handler.service_handler(name="")(object) + + +def test_name_and_interface_are_mutually_exclusive(): + with pytest.raises(ValueError): + nexusrpc.handler.service_handler( + name="my-service-impl-🌈", service=ServiceInterface + ) # type: ignore (enforced by overloads) 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..a3d26e6 --- /dev/null +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -0,0 +1,152 @@ +from typing import Optional, Type + +import pytest + +import nexusrpc +import nexusrpc.handler + + +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: + @nexusrpc.handler.sync_operation_handler + async def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: None + ) -> None: ... + + error_message = None + + +class ValidImplWithEmptyInterfaceAndExtraOperation(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + pass + + class Impl: + @nexusrpc.handler.sync_operation_handler + async def extra_op( + self, ctx: nexusrpc.handler.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] + + class Impl: + @nexusrpc.handler.sync_operation_handler + async def op(self, ctx, input): ... + + error_message = None + + +class MissingOperation(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, None] + + class Impl: + pass + + error_message = "does not implement operation 'op'" + + +class MissingInputAnnotation(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, None] + + class Impl: + @nexusrpc.handler.sync_operation_handler + async def op( + self, ctx: nexusrpc.handler.StartOperationContext, input + ) -> None: ... + + error_message = None + + +class MissingOptionsAnnotation(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, None] + + class Impl: + @nexusrpc.handler.sync_operation_handler + async def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: str + ) -> None: ... + + error_message = "does not match the input type" + + +class WrongOutputType(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[None, int] + + class Impl: + @nexusrpc.handler.sync_operation_handler + async def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: None + ) -> str: ... + + error_message = "does not match the output type" + + +@pytest.mark.parametrize( + "test_case", + [ + ValidImpl, + ValidImplWithEmptyInterfaceAndExtraOperation, + ValidImplWithoutTypeAnnotations, + MissingOperation, + MissingInputAnnotation, + MissingOptionsAnnotation, + WrongOutputType, + ], +) +def test_service_decorator_enforces_interface_implementation( + test_case: Type[_InterfaceImplementationTestCase], +): + if test_case.error_message: + with pytest.raises(Exception) as ei: + nexusrpc.handler.service_handler(service=test_case.Interface)( + test_case.Impl + ) + err = ei.value + assert test_case.error_message in str(err) + else: + nexusrpc.handler.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: + @nexusrpc.handler.operation_handler + def operation_b(self) -> nexusrpc.handler.OperationHandler[None, None]: ... + + with pytest.raises( + TypeError, + match="does not match an operation method name in the service definition", + ): + nexusrpc.handler.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..386a384 --- /dev/null +++ b/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py @@ -0,0 +1,36 @@ +from typing import Any, Type + +import pytest + +import nexusrpc + + +class _TestCase: + UserServiceHandler: Type[Any] + expected_error_message: str + + +class DuplicateOperationName(_TestCase): + class UserServiceHandler: + @nexusrpc.handler.operation_handler(name="a") + def op_1(self) -> nexusrpc.handler.OperationHandler[int, int]: ... + + @nexusrpc.handler.sync_operation_handler(name="a") + def op_2( + self, ctx: nexusrpc.handler.StartOperationContext, input: 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): + nexusrpc.handler.service_handler(test_case.UserServiceHandler) 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..8da9a1c --- /dev/null +++ b/tests/handler/test_sync_operation_handler_decorator_creates_valid_operation_handler.py @@ -0,0 +1,63 @@ +from unittest import mock + +import pytest + +import nexusrpc +from nexusrpc.handler._util import is_async_callable + + +@nexusrpc.handler.service_handler +class MyServiceHandler: + def __init__(self): + self.mutable_container = [] + + @nexusrpc.handler.sync_operation_handler + def my_def_op(self, ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: + """ + This is the docstring for the `my_def_op` sync operation. + """ + self.mutable_container.append(input) + return input + 1 + + @nexusrpc.handler.sync_operation_handler + async def my_async_def_op( + self, ctx: nexusrpc.handler.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 = user_instance.my_def_op() + 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=nexusrpc.handler.StartOperationContext) + result = op_handler.start(ctx, 1) + assert isinstance(result, nexusrpc.handler.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 = user_instance.my_async_def_op() + 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=nexusrpc.handler.StartOperationContext) + result = await op_handler.start(ctx, 1) + assert isinstance(result, nexusrpc.handler.StartOperationResultSync) + assert result.value == 3 + assert user_instance.mutable_container == [1] 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..4524ae8 --- /dev/null +++ b/tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py @@ -0,0 +1,45 @@ +from typing import Any, Type + +import pytest + +import nexusrpc + + +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], +): + metadata = getattr(test_case.Interface, "__nexus_service__") + assert isinstance(metadata, nexusrpc.ServiceDefinition) + actual_ops = { + op.key: (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..f749dba --- /dev/null +++ b/tests/service_definition/test_service_decorator_selects_correct_service_name.py @@ -0,0 +1,52 @@ +from typing import Type + +import pytest + +import nexusrpc._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]): + metadata = getattr(test_case.Interface, "__nexus_service__") + assert metadata.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..a5667ca --- /dev/null +++ b/tests/service_definition/test_service_decorator_validation.py @@ -0,0 +1,43 @@ +from typing import Type + +import pytest + +import nexusrpc + +# TODO(preview): test error message when incorrectly applying service decorator to handler class +# TODO(preview): test error message when incorrectly applying service_handler decorator to definition class + + +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( + "Operation a in service Contract 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/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py new file mode 100644 index 0000000..f89b018 --- /dev/null +++ b/tests/test_get_input_and_output_types.py @@ -0,0 +1,134 @@ +import warnings +from typing import ( + Any, + Awaitable, + Callable, + Type, + Union, + get_args, + get_origin, +) + +import pytest + +from nexusrpc.handler import ( + MISSING_TYPE, + StartOperationContext, + get_start_method_input_and_output_types_annotations, +) + + +class Input: + pass + + +class Output: + pass + + +class _TestCase: + start: Callable + expected_types: tuple[Type[Any], Type[Any]] + + +class SyncMethod(_TestCase): + 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): + def start( + self, ctx: StartOperationContext, i: Input + ) -> Union[Output, Awaitable[Output]]: ... + + expected_types = (Input, Union[Output, Awaitable[Output]]) + + +class MissingInputAnnotationInUnionMethod(_TestCase): + def start( + self, ctx: StartOperationContext, i + ) -> Union[Output, Awaitable[Output]]: ... + + expected_types = (MISSING_TYPE, Union[Output, Awaitable[Output]]) + + +class TooFewParams(_TestCase): + def start(self, i: Input) -> Output: ... + + expected_types = (MISSING_TYPE, Output) + + +class TooManyParams(_TestCase): + def start(self, ctx: StartOperationContext, i: Input, extra: int) -> Output: ... + + expected_types = (MISSING_TYPE, Output) + + +class WrongOptionsType(_TestCase): + def start(self, ctx: int, i: Input) -> Output: ... + + expected_types = (MISSING_TYPE, Output) + + +class NoReturnHint(_TestCase): + def start(self, ctx: StartOperationContext, i: Input): ... + + expected_types = (Input, MISSING_TYPE) + + +class NoInputAnnotation(_TestCase): + def start(self, ctx: StartOperationContext, i) -> Output: ... + + expected_types = (MISSING_TYPE, Output) + + +class NoOptionsAnnotation(_TestCase): + def start(self, ctx, i: Input) -> Output: ... + + expected_types = (MISSING_TYPE, Output) + + +class AllAnnotationsMissing(_TestCase): + def start(self, ctx: StartOperationContext, i: Input): ... + + expected_types = (Input, MISSING_TYPE) + + +@pytest.mark.parametrize( + "test_case", + [ + SyncMethod, + AsyncMethod, + UnionMethod, + TooFewParams, + TooManyParams, + WrongOptionsType, + NoReturnHint, + NoInputAnnotation, + NoOptionsAnnotation, + MissingInputAnnotationInUnionMethod, + AllAnnotationsMissing, + ], +) +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_types_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_util.py b/tests/test_util.py new file mode 100644 index 0000000..17f3d36 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,23 @@ +from functools import partial + +from nexusrpc.handler._util import is_async_callable + + +def test_async_def_is_async_callable(): + async def f(a: int, b: int) -> None: + pass + + assert is_async_callable(f) + assert is_async_callable(partial(f, a=1)) + + +def test_async_callable_instance_is_async_callable(): + class f_cls: + async def __call__(self, a: int, b: int) -> None: + pass + + f = f_cls() + g = partial(f, a=1) + + assert is_async_callable(f) + assert is_async_callable(g) diff --git a/uv.lock b/uv.lock index 5b7d681..b4b7e9a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,261 @@ 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 = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[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 = "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 = "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 = "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 = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "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 = "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 = "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 = "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" @@ -9,14 +265,230 @@ dependencies = [ { name = "typing-extensions" }, ] +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "mypy" }, + { 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 = "httpx", specifier = ">=0.28.1" }, + { name = "mypy", specifier = ">=1.15.0" }, + { name = "pyright", specifier = ">=1.1.400" }, + { 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.11.7" }, +] + +[[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 = "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 = "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.400" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/cb/c306618a02d0ee8aed5fb8d0fe0ecfed0dbf075f71468f03a30b5f4e1fe0/pyright-1.1.400.tar.gz", hash = "sha256:b8a3ba40481aa47ba08ffb3228e821d22f7d391f83609211335858bf05686bdb", size = 3846546, upload-time = "2025-04-24T12:55:18.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/a5/5d285e4932cf149c90e3c425610c5efaea005475d5f96f1bfdb452956c62/pyright-1.1.400-py3-none-any.whl", hash = "sha256:c80d04f98b5a4358ad3a35e241dbf2a408eee33a40779df365644f8054d2517e", size = 5563460, upload-time = "2025-04-24T12:55:17.002Z" }, +] + +[[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 = "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.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/89/6f9c9674818ac2e9cc2f2b35b704b7768656e6b7c139064fc7ba8fbc99f1/ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4", size = 4054861, upload-time = "2025-04-24T18:49:37.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ec/21927cb906c5614b786d1621dba405e3d44f6e473872e6df5d1a6bca0455/ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c", size = 10245403, upload-time = "2025-04-24T18:48:40.459Z" }, + { url = "https://files.pythonhosted.org/packages/e2/af/fec85b6c2c725bcb062a354dd7cbc1eed53c33ff3aa665165871c9c16ddf/ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee", size = 11007166, upload-time = "2025-04-24T18:48:44.742Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/2d0d260a58e81f388800343a45898fd8df73c608b8261c370058b675319a/ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada", size = 10378076, upload-time = "2025-04-24T18:48:47.918Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/9b09b45051404d2e7dd6d9dbcbabaa5ab0093f9febcae664876a77b9ad53/ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64", size = 10557138, upload-time = "2025-04-24T18:48:51.707Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5e/f62a1b6669870a591ed7db771c332fabb30f83c967f376b05e7c91bccd14/ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201", size = 10095726, upload-time = "2025-04-24T18:48:54.243Z" }, + { url = "https://files.pythonhosted.org/packages/45/59/a7aa8e716f4cbe07c3500a391e58c52caf665bb242bf8be42c62adef649c/ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6", size = 11672265, upload-time = "2025-04-24T18:48:57.639Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e3/101a8b707481f37aca5f0fcc3e42932fa38b51add87bfbd8e41ab14adb24/ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4", size = 12331418, upload-time = "2025-04-24T18:49:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/71/037f76cbe712f5cbc7b852e4916cd3cf32301a30351818d32ab71580d1c0/ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e", size = 11794506, upload-time = "2025-04-24T18:49:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/e450b6bab1fc60ef263ef8fcda077fb4977601184877dce1c59109356084/ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63", size = 13939084, upload-time = "2025-04-24T18:49:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2c/1e364cc92970075d7d04c69c928430b23e43a433f044474f57e425cbed37/ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502", size = 11450441, upload-time = "2025-04-24T18:49:11.41Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/1b048eb460517ff9accd78bca0fa6ae61df2b276010538e586f834f5e402/ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92", size = 10441060, upload-time = "2025-04-24T18:49:14.184Z" }, + { url = "https://files.pythonhosted.org/packages/3a/57/8dc6ccfd8380e5ca3d13ff7591e8ba46a3b330323515a4996b991b10bd5d/ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94", size = 10058689, upload-time = "2025-04-24T18:49:17.559Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/20487561ed72654147817885559ba2aa705272d8b5dee7654d3ef2dbf912/ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6", size = 11073703, upload-time = "2025-04-24T18:49:20.247Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/04f2db95f4ef73dccedd0c21daf9991cc3b7f29901a4362057b132075aa4/ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6", size = 11532822, upload-time = "2025-04-24T18:49:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/e1/72/43b123e4db52144c8add336581de52185097545981ff6e9e58a21861c250/ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26", size = 10362436, upload-time = "2025-04-24T18:49:27.377Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a0/3e58cd76fdee53d5c8ce7a56d84540833f924ccdf2c7d657cb009e604d82/ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a", size = 11566676, upload-time = "2025-04-24T18:49:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/68/ca/69d7c7752bce162d1516e5592b1cc6b6668e9328c0d270609ddbeeadd7cf/ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177", size = 10677936, upload-time = "2025-04-24T18:49:34.392Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "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 = "typing-extensions" -version = "4.12.2" +version = "4.13.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 } +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/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { 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" }, ] From 6e6a28a244a60b0ebf4cf76b3c39f5d91b87b938 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 9 Jun 2025 15:53:27 -0400 Subject: [PATCH 002/178] Generate API docs --- .github/workflows/ci.yml | 10 ++ .gitignore | 1 + CONTRIBUTING.md | 5 + pyproject.toml | 1 + uv.lock | 379 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 396 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a51365c..6762174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,3 +47,13 @@ jobs: with: name: coverage-html-report-${{ matrix.python-version }} path: coverage_html_report/ + + - name: Build API docs + run: uv run pydoctor src/nexusrpc + # TODO(prerelease) + # - name: Deploy prod API docs + # if: ${{ github.ref == 'refs/heads/main' }} + # env: + # VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + # VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + # run: npx vercel deploy build/apidocs -t ${{ secrets.VERCEL_TOKEN }} --prod --yes \ No newline at end of file diff --git a/.gitignore b/.gitignore index 679a63d..60cde98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ .venv +apidocs dist docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e915182..14def33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,4 +11,9 @@ uv run ruff format --check ``` uv run ruff check --select I --fix uv run ruff format +``` + +### API docs +``` +uv run pydoctor src/nexusrpc ``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4f563b5..391d063 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ dev = [ "httpx>=0.28.1", "mypy>=1.15.0", + "pydoctor>=25.4.0", "pyright>=1.1.400", "pytest>=8.3.5", "pytest-asyncio>=0.26.0", diff --git a/uv.lock b/uv.lock index b4b7e9a..c5a8bab 100644 --- a/uv.lock +++ b/uv.lock @@ -22,6 +22,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[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" @@ -31,6 +70,80 @@ 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" @@ -40,6 +153,24 @@ 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" @@ -119,6 +250,15 @@ 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" @@ -128,6 +268,15 @@ 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 = "h11" version = "0.16.0" @@ -165,6 +314,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "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" @@ -174,6 +335,19 @@ 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" @@ -183,6 +357,15 @@ 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" @@ -204,6 +387,69 @@ 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" @@ -269,6 +515,7 @@ dependencies = [ dev = [ { name = "httpx" }, { name = "mypy" }, + { name = "pydoctor" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -284,6 +531,7 @@ requires-dist = [{ name = "typing-extensions", specifier = ">=4.12.2" }] dev = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", specifier = ">=1.15.0" }, + { name = "pydoctor", specifier = ">=25.4.0" }, { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, @@ -310,6 +558,15 @@ 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 = "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" @@ -319,6 +576,27 @@ 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 = "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" @@ -397,6 +675,21 @@ 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 = "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" @@ -436,6 +729,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/ca/69d7c7752bce162d1516e5592b1cc6b6668e9328c0d270609ddbeeadd7cf/ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177", size = 10677936, upload-time = "2025-04-24T18:49:34.392Z" }, ] +[[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 = "sniffio" version = "1.3.1" @@ -445,6 +747,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "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" @@ -484,6 +795,24 @@ wheels = [ { 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.13.2" @@ -492,3 +821,53 @@ sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e3549295 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" } +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/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" }, +] From c4c97407b1cd359c50adb18d18bd8ab308280139 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 9 Jun 2025 19:42:04 -0400 Subject: [PATCH 003/178] Changes based on review comments Delete .coverage Resurrect py.typed --- .coverage | Bin 53248 -> 0 bytes src/nexusrpc/__init__.py | 1 - src/nexusrpc/handler/_core.py | 28 ++++++++---------- src/nexusrpc/py.typed | 0 ...er_validates_service_handler_collection.py | 2 +- ...tor_validates_duplicate_operation_names.py | 2 +- ...corator_creates_valid_operation_handler.py | 2 +- 7 files changed, 16 insertions(+), 19 deletions(-) delete mode 100644 .coverage create mode 100644 src/nexusrpc/py.typed diff --git a/.coverage b/.coverage deleted file mode 100644 index ef808fad6035f43980215d869ae20308fdb763f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI4U2Ggz6~||GW_Nb%U0(+iw!wBiX+xbQwl}5^7*{|Iq$Q9hX&Wj5qU+h+yY|rC zna#{>Vx!V#Q=|X_sp1W(;*}Q!NFd;Whe*63rKRc%2%A{7Er#D}2afpg}=>viI) ziY+xw|5v+rX70zCbAI>Sd*|-#%%cwXMmZd0)N#7!U(_qk! zo*vLv-L${muBpsD_4%TBOey5Pt%%2qC&X;wx#B&;FBaP7H;2Dx*k+wF5I_I~KmY{Z z-2{3M7mU)red_tIhIXwbL*K4TKaPu^eB{W|sUy~@rB56?V#R6J!6A#n?5wq9`QBNp zBYmssw4~*@4X190j=N%ot1>O!4P=8SI!dFdjtkC3^?9%AG^kc6S4c$1ciOhUZapE_ zXQBeh*+RJ%#t9@uIx8-huns2G%a$*j(wA;s2C)_gokn@6{LIrsMrmwJy&xl-Y`;#Q z;Q?zXp>N93CJCy0euK5I`LCOZAfH>jBuPbxP_IoC_E_ABzKsdA*V+ilkf z`Io!x4mS?fh)*1P+tu8zZs;Y(zU8*LL0k6i9ca!JIk#wBtLr+ayK4G@SM9|;g2yHPW(SPmlztc3G7-FW^*eUBYF)28 zCzImS*<9(qz1u0yDpcn=&OP(ROr-LKeG&aO4f_1m22|#hzRI7K>$7L<7L6ty+wrN( z(ij}i02UW)*i_iAdEGGbUp|c7ljokkzOjJ*amt=bm80U+y-#{U;LxXi%JrsHqjczy z+Uq8rF&<~C0Zr;{J5@V5%nUfmmdw7toS*@hu68#23A5XTgAd~~@Zc7=U@fMQmP)R5 zhRVFaSH(4jJ_sNH0w4eaAOHd&00JNY0w4eaAOHfl9|29xs0Oe9GvbOO{zef32!H?x zfB*=900@8p2!H?xfB*=9z&n#bA)`%-^e-NVg{qC0l0N}_cy3|tLqlX$M!cqo*Tlcx znFc~@5C8!X009sH0T2KI5C8!X009sHfs{Z&n^en;-VscE?hA= za%tqLk;BD56u(YM2p|9gAOHd&00JNY0w4eaAOHdpfn%nw^d>8x3+O5JO2c+5fnTqL zvfc4~yXANlSFUw~*`V>nY-c@O_2T>}(eKnNtMnLrOZt^+H*{L`-1;MVl2+cavm+Xfj>Yj?3OtwO zozO_$-8+_7bzCQ`R!QK484@^lvjX|O81zb{kOiqb5mfDK#P|Qr;(dzvh493kBhQb_ z7r#-wJn}RpA%Fk~fB*=900@8p2!H?xfB*=@1Rm0Rlj_Z`<+iT>%}4cKdFRpw*8ln4 zkvXvbH+IMBWc{BzuG1^>cFZtc|7TC=z5P2Ex3T`$PwKt9cP_8L+SWd;_r`8o;HLF| z=0Or<-YmFH>wmoe55pMfoD5G5gi00@8p z2!H?xfB*=900@8p2!O!5ftC>R<#&%aFa4nTs-un?RP-+6{TG*(b4$t{872SG9Hl&&dqe%H@{<|k zy{}k5VubfAc}1lg@C(ZFKVDy;y}~(F(F%7g>&j?e*I&5wo?pH^mcOVC8Cj}6n*DQO zLZ_WYeX;SM7Y>eg9$0vG;)BMX{eSE%eCz776P-O8z2p|9gAOHd& z00JNY0w4eaAOHd&00OrT0sdt{HO**~&qo`>h&H)gw8>_pjjl% Union[OperationInfo, Awaitable[OperationInfo]]: + ) -> OperationInfo: """Handle a Fetch Operation Info request. Args: @@ -153,7 +153,7 @@ async def fetch_operation_info( async def fetch_operation_result( self, ctx: FetchOperationResultContext, token: str - ) -> Union[Any, Awaitable[Any]]: + ) -> Any: """Handle a Fetch Operation Result request. Args: @@ -162,9 +162,7 @@ async def fetch_operation_result( """ raise NotImplementedError - async def cancel_operation( - self, ctx: CancelOperationContext, token: str - ) -> Union[None, Awaitable[None]]: + async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: """Handle a Cancel Operation request. Args: @@ -176,12 +174,12 @@ async def cancel_operation( if is_async_callable(op_handler.cancel): return await op_handler.cancel(ctx, token) else: - if not self.executor: + if not self.sync_executor: raise RuntimeError( "Operation cancel handler method is async but " "no executor was provided to the Handler constructor." ) - result = await self.executor.run_sync(op_handler.cancel, ctx, token) + result = await self.sync_executor.run_sync(op_handler.cancel, ctx, token) if inspect.isawaitable(result): raise RuntimeError( f"Operation start handler method {op_handler.cancel} returned an " diff --git a/src/nexusrpc/py.typed b/src/nexusrpc/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index f1c3473..7863ca2 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -5,7 +5,7 @@ import pytest -import nexusrpc +import nexusrpc.handler from nexusrpc.handler import Handler 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 index 386a384..0dae6fe 100644 --- a/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py +++ b/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py @@ -2,7 +2,7 @@ import pytest -import nexusrpc +import nexusrpc.handler class _TestCase: 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 index 8da9a1c..f161ab0 100644 --- 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 @@ -2,7 +2,7 @@ import pytest -import nexusrpc +import nexusrpc.handler from nexusrpc.handler._util import is_async_callable From 89fd030d2e851094d106f01e24895c34eee27ccf Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 9 Jun 2025 19:55:53 -0400 Subject: [PATCH 004/178] Move types --- src/nexusrpc/_service_definition.py | 2 +- src/nexusrpc/handler/__init__.py | 2 +- src/nexusrpc/handler/_common.py | 2 +- src/nexusrpc/handler/_core.py | 2 +- src/nexusrpc/handler/_decorators.py | 2 +- src/nexusrpc/handler/_util.py | 2 +- src/nexusrpc/{handler/_types.py => types.py} | 0 ...r_results_in_correctly_functioning_operation_factories.py | 5 +---- 8 files changed, 7 insertions(+), 10 deletions(-) rename src/nexusrpc/{handler/_types.py => types.py} (100%) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 9100c18..c06598a 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -19,7 +19,7 @@ overload, ) -from .handler._types import ( +from nexusrpc.types import ( InputT, OutputT, ServiceDefinitionT, diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index dfe1833..67ed307 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -82,7 +82,7 @@ from ._serializer import ( Serializer as Serializer, ) -from ._types import ( +from nexusrpc.types import ( MISSING_TYPE as MISSING_TYPE, ) from ._util import ( diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 1b35fe7..c8194f6 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -7,7 +7,7 @@ Optional, ) -from ._types import OutputT +from nexusrpc.types import OutputT class HandlerErrorType(Enum): diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 58000ae..346d65a 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -23,6 +23,7 @@ import nexusrpc import nexusrpc._service_definition from nexusrpc.handler._util import is_async_callable +from nexusrpc.types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT from ._common import ( CancelOperationContext, @@ -34,7 +35,6 @@ StartOperationResultSync, ) from ._serializer import LazyValue -from ._types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT class UnknownServiceError(RuntimeError): diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index a78d3ce..13857d7 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -29,7 +29,7 @@ service_from_operation_handler_methods, validate_operation_handler_methods, ) -from ._types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT +from nexusrpc.types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT from ._util import ( get_start_method_input_and_output_types_annotations, is_async_callable, diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index e00b139..8e8f296 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -15,7 +15,7 @@ from typing_extensions import TypeGuard from ._common import StartOperationContext -from ._types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT +from nexusrpc.types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT def get_start_method_input_and_output_types_annotations( diff --git a/src/nexusrpc/handler/_types.py b/src/nexusrpc/types.py similarity index 100% rename from src/nexusrpc/handler/_types.py rename to src/nexusrpc/types.py 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 index a59185d..83d5247 100644 --- 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 @@ -9,11 +9,8 @@ import nexusrpc._service_definition import nexusrpc.handler +from nexusrpc.types import InputT, OutputT from nexusrpc.handler._core import collect_operation_handler_factories -from nexusrpc.handler._types import ( - InputT, - OutputT, -) from nexusrpc.handler._util import is_async_callable From 4c6bdc8add88bed669d9c5e6a989ffb575f7dee7 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 9 Jun 2025 20:04:23 -0400 Subject: [PATCH 005/178] Eliminate Operation.key --- src/nexusrpc/_service_definition.py | 14 +++++--------- src/nexusrpc/handler/_core.py | 10 +++++----- ...rator_creates_expected_operation_declaration.py | 2 +- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index c06598a..f3c97f4 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -49,15 +49,11 @@ class MyNexusService: my_operation: nexusrpc.Operation[MyInput, MyOutput] """ - name: Optional[str] = None + name: str method_name: str = dataclasses.field(init=False) input_type: Type[InputT] = dataclasses.field(init=False) output_type: Type[OutputT] = dataclasses.field(init=False) - @property - def key(self) -> str: - return self.name or self.method_name - @classmethod def _create( cls, @@ -67,7 +63,7 @@ def _create( input_type: Type, output_type: Type, ) -> Operation: - op = cls(name) + op = cls(name or method_name) op.method_name = method_name op.input_type = input_type op.output_type = output_type @@ -151,11 +147,11 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: op.input_type = input_type op.output_type = output_type - if op.key in operations: + if op.name in operations: raise ValueError( - f"Operation {op.key} in service {service_name} is defined multiple times" + f"Operation {op.name} in service {service_name} is defined multiple times" ) - operations[op.key] = op + operations[op.name] = op cls.__nexus_service__ = ServiceDefinition( # type: ignore name=service_name, diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 346d65a..e3e2433 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -407,10 +407,10 @@ def collect_operation_handler_factories( op_defn = getattr(method, "__nexus_operation__", None) if isinstance(op_defn, nexusrpc.Operation): # This is a method decorated with one of the *operation_handler decorators - # assert op_defn.key == name - if op_defn.key in factories: + # assert op_defn.name == name + if op_defn.name in factories: raise RuntimeError( - f"Operation '{op_defn.key}' in service '{user_service_cls.__name__}' " + f"Operation '{op_defn.name}' in service '{user_service_cls.__name__}' " f"is defined multiple times." ) if service and name not in op_method_names: @@ -421,7 +421,7 @@ def collect_operation_handler_factories( f"Available method names in the service definition: {method_names}." ) - factories[op_defn.key] = method + factories[op_defn.name] = method # Check for accidentally missing decorator on an OperationHandler factory # TODO(preview): support disabling warning in @service_handler decorator? elif ( @@ -504,7 +504,7 @@ def service_from_operation_handler_methods( f":py:func:`@nexusrpc.handler.operation_handler` or " f":py:func:`@nexusrpc.handler.sync_operation_handler`?" ) - operations[op.name or op.method_name] = op + operations[op.name] = op return nexusrpc.ServiceDefinition(name=service_name, operations=operations) 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 index 4524ae8..acb99cb 100644 --- a/tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py +++ b/tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py @@ -38,7 +38,7 @@ def test_interface_operation_declarations( metadata = getattr(test_case.Interface, "__nexus_service__") assert isinstance(metadata, nexusrpc.ServiceDefinition) actual_ops = { - op.key: (op.input_type, op.output_type) + op.name: (op.input_type, op.output_type) for op in test_case.Interface.__dict__.values() if isinstance(op, nexusrpc.Operation) } From 636741104f4f9d781de26960feabef58f22d3cac Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 9 Jun 2025 20:12:05 -0400 Subject: [PATCH 006/178] Use Mapping for headers --- src/nexusrpc/handler/_common.py | 5 +++-- src/nexusrpc/testing/client.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index c8194f6..a7e3e70 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -4,6 +4,7 @@ from enum import Enum from typing import ( Generic, + Mapping, Optional, ) @@ -96,7 +97,7 @@ class OperationContext: # The name of the operation. operation: str # Optional header fields sent by the caller. - headers: dict[str, str] = field(default_factory=dict) + headers: Mapping[str, str] = field(default_factory=dict) @dataclass @@ -111,7 +112,7 @@ class StartOperationContext(OperationContext): # Optional header fields set by the caller to be attached to the callback request when an # asynchronous operation completes. - callback_header: dict[str, str] = field(default_factory=dict) + callback_headers: Mapping[str, str] = field(default_factory=dict) # Request ID that may be used by the server handler to dedupe a start request. # By default a v4 UUID will be generated by the client. diff --git a/src/nexusrpc/testing/client.py b/src/nexusrpc/testing/client.py index bdb8af4..724ab8e 100644 --- a/src/nexusrpc/testing/client.py +++ b/src/nexusrpc/testing/client.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any +from typing import Any, Mapping import httpx @@ -14,7 +14,7 @@ async def start_operation( self, operation: str, body: dict[str, Any], - headers: dict[str, str] = {}, + headers: Mapping[str, str] = {}, ) -> httpx.Response: """ Start a Nexus operation. From a94f1818314bf291362d4edfb13584fa564aa83c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 9 Jun 2025 20:14:30 -0400 Subject: [PATCH 007/178] s/status/state/ --- src/nexusrpc/handler/_common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index a7e3e70..a8a151a 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -170,8 +170,8 @@ class OperationInfo: # Token identifying the operation (returned on operation start). token: str - # The operation's status. - status: OperationState + # The operation's state + state: OperationState # TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? From 29a55044a0f129593a25edcf6af48fe579de3f63 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 9 Jun 2025 20:20:30 -0400 Subject: [PATCH 008/178] Delete ServiceHandlerDefinition --- src/nexusrpc/handler/_core.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index e3e2433..927004c 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -198,17 +198,6 @@ def _get_service_handler(self, service_name: str) -> ServiceHandler: return service -@dataclass -class ServiceHandlerDefinition: - """Internal representation of a user's Nexus service implementation class. - - This class is not part of the public API. - """ - - service: nexusrpc.ServiceDefinition - operation_handler_factories: dict[str, Callable[[Any], OperationHandler[Any, Any]]] - - @dataclass class ServiceHandler: """Internal representation of a user's Nexus service implementation instance. From a5cc3b45ed3de457cc70ed990a6dd274ba55ec8f Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 9 Jun 2025 23:00:05 -0400 Subject: [PATCH 009/178] Add failing test --- ...test_service_handler_from_user_instance.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/handler/test_service_handler_from_user_instance.py 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..faa461e --- /dev/null +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pytest + +import nexusrpc.handler +from nexusrpc.handler._core import ServiceHandler + + +@nexusrpc.handler.service_handler +class MyServiceHandlerWithCallableInstance: + class SyncOperationWithCallableInstance: + def __call__( + self, + _handler: MyServiceHandlerWithCallableInstance, + ctx: nexusrpc.handler.StartOperationContext, + input: int, + ) -> int: + return input + + sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( + name="sync_operation_with_callable_instance", + )( + SyncOperationWithCallableInstance(), + ) + + +@pytest.mark.skip( + reason="TODO(preview): fix method name bug in absence of service definition" +) +def test_service_handler_from_user_instance(): + service_handler = MyServiceHandlerWithCallableInstance() + ServiceHandler.from_user_instance(service_handler) From 26f552245e820a60d96c2f3d17001fffc415b03a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 10 Jun 2025 11:24:59 -0400 Subject: [PATCH 010/178] Bug fix --- src/nexusrpc/handler/_core.py | 14 +++++++------- src/nexusrpc/handler/_decorators.py | 6 +++++- ...ator_collects_expected_operation_definitions.py | 11 +++++++---- .../test_service_handler_from_user_instance.py | 7 ++----- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 927004c..5d35bd6 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -389,10 +389,10 @@ def collect_operation_handler_factories( Collect operation handler methods from a user service handler class. """ factories = {} - op_method_names = ( + op_defn_method_names = ( {op.method_name for op in service.operations.values()} if service else set() ) - for name, method in inspect.getmembers(user_service_cls, inspect.isfunction): + for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): op_defn = getattr(method, "__nexus_operation__", None) if isinstance(op_defn, nexusrpc.Operation): # This is a method decorated with one of the *operation_handler decorators @@ -402,12 +402,12 @@ def collect_operation_handler_factories( f"Operation '{op_defn.name}' in service '{user_service_cls.__name__}' " f"is defined multiple times." ) - if service and name not in op_method_names: - method_names = ", ".join(f"'{s}'" for s in sorted(op_method_names)) + if service and op_defn.method_name not in op_defn_method_names: + _names = ", ".join(f"'{s}'" for s in sorted(op_defn_method_names)) raise TypeError( - f"Operation method name '{name}' in service handler {user_service_cls} " + 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: {method_names}." + f"Available method names in the service definition: {_names}." ) factories[op_defn.name] = method @@ -418,7 +418,7 @@ def collect_operation_handler_factories( == OperationHandler ): warnings.warn( - f"Method '{name}' in class '{user_service_cls.__name__}' " + f"Method '{method}' in class '{user_service_cls}' " f"returns OperationHandler but has not been decorated. " f"Did you forget to apply the @nexusrpc.handler.operation_handler decorator?", UserWarning, diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 13857d7..426adbb 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -337,7 +337,11 @@ def cancel(_, ctx: StartOperationContext, token: str): start_method ) method_name = getattr(start_method, "__name__", None) - if not method_name and callable(start_method): + if ( + not method_name + and callable(start_method) + and hasattr(start_method, "__call__") + ): method_name = start_method.__class__.__name__ if not method_name: raise TypeError( 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 index fb59f94..94abf86 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -143,7 +143,7 @@ class Contract: @nexusrpc.handler.service_handler(service=Contract) class Service: - class CallableInstanceStartMethod: + class sync_operation_with_callable_instance: def __call__( self, _handler: Any, @@ -151,9 +151,12 @@ def __call__( input: Input, ) -> Output: ... - sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( - name="sync_operation_with_callable_instance", - )(CallableInstanceStartMethod()) + # TODO(preview): improve the DX here. The decorator cannot be placed on the + # callable class itself, because the user must be responsible for instantiating + # the class to obtain the callable instance. + sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( # type: ignore + sync_operation_with_callable_instance() + ) expected_operations = { "sync_operation_with_callable_instance": nexusrpc.Operation._create( diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py index faa461e..9578491 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,10 +1,10 @@ from __future__ import annotations -import pytest - import nexusrpc.handler from nexusrpc.handler._core import ServiceHandler +# TODO(preview): test operation_handler version of this + @nexusrpc.handler.service_handler class MyServiceHandlerWithCallableInstance: @@ -24,9 +24,6 @@ def __call__( ) -@pytest.mark.skip( - reason="TODO(preview): fix method name bug in absence of service definition" -) def test_service_handler_from_user_instance(): service_handler = MyServiceHandlerWithCallableInstance() ServiceHandler.from_user_instance(service_handler) From e67dbef20acca0a2eeb59353be341d9f1b27594c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 11 Jun 2025 07:52:31 -0400 Subject: [PATCH 011/178] Remove MISSING_TYPE --- src/nexusrpc/_service_definition.py | 8 ++++---- src/nexusrpc/handler/__init__.py | 3 --- src/nexusrpc/handler/_core.py | 6 +++--- src/nexusrpc/handler/_decorators.py | 6 +++--- src/nexusrpc/handler/_util.py | 15 ++++++++------- src/nexusrpc/types.py | 11 ----------- tests/test_get_input_and_output_types.py | 19 +++++++++---------- 7 files changed, 27 insertions(+), 41 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index f3c97f4..467a113 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -51,8 +51,8 @@ class MyNexusService: name: str method_name: str = dataclasses.field(init=False) - input_type: Type[InputT] = dataclasses.field(init=False) - output_type: Type[OutputT] = dataclasses.field(init=False) + input_type: Any = dataclasses.field(init=False) + output_type: Any = dataclasses.field(init=False) @classmethod def _create( @@ -60,8 +60,8 @@ def _create( *, name: Optional[str] = None, method_name: str, - input_type: Type, - output_type: Type, + input_type: Any, + output_type: Any, ) -> Operation: op = cls(name or method_name) op.method_name = method_name diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 67ed307..0e00b25 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -82,9 +82,6 @@ from ._serializer import ( Serializer as Serializer, ) -from nexusrpc.types import ( - MISSING_TYPE as MISSING_TYPE, -) from ._util import ( get_start_method_input_and_output_types_annotations as get_start_method_input_and_output_types_annotations, ) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 5d35bd6..3824213 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -23,7 +23,7 @@ import nexusrpc import nexusrpc._service_definition from nexusrpc.handler._util import is_async_callable -from nexusrpc.types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT +from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._common import ( CancelOperationContext, @@ -448,12 +448,12 @@ def validate_operation_handler_methods( f":py:func:`@nexusrpc.handler.operation_handler` or " f":py:func:`@nexusrpc.handler.sync_operation_handler`?" ) - if op.input_type != op_defn.input_type and op.input_type != MISSING_TYPE: + if op.input_type != op_defn.input_type and op.input_type != Any: raise TypeError( f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " f"which does not match the input type '{op_defn.input_type}' in interface '{service_definition}'." ) - if op.output_type != op_defn.output_type and op.output_type != MISSING_TYPE: + if op.output_type != op_defn.output_type and op.output_type != Any: raise TypeError( f"Operation '{op_name}' in service '{user_service_cls}' has output type '{op.output_type}', " f"which does not match the output type '{op_defn.output_type}' in interface '{service_definition}'." diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 426adbb..37e37ef 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -17,6 +17,7 @@ ) import nexusrpc +from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._common import ( StartOperationContext, @@ -29,7 +30,6 @@ service_from_operation_handler_methods, validate_operation_handler_methods, ) -from nexusrpc.types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT from ._util import ( get_start_method_input_and_output_types_annotations, is_async_callable, @@ -183,8 +183,8 @@ def decorator( method: OperationHandlerFactoryT, ) -> OperationHandlerFactoryT: # Extract input and output types from the return type annotation - input_type = MISSING_TYPE - output_type = MISSING_TYPE + input_type = Any + output_type = Any return_type = typing.get_type_hints(method).get("return") if typing.get_origin(return_type) == OperationHandler: diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 8e8f296..6ce5e3e 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -14,8 +14,9 @@ from typing_extensions import TypeGuard +from nexusrpc.types import InputT, OutputT, ServiceHandlerT + from ._common import StartOperationContext -from nexusrpc.types import MISSING_TYPE, InputT, OutputT, ServiceHandlerT def get_start_method_input_and_output_types_annotations( @@ -24,8 +25,8 @@ def get_start_method_input_and_output_types_annotations( Union[OutputT, Awaitable[OutputT]], ], ) -> tuple[ - Union[Type[InputT], Type[MISSING_TYPE]], - Union[Type[OutputT], Type[MISSING_TYPE]], + Union[Type[InputT], Any], + Union[Type[OutputT], Any], ]: """Return operation input and output types. @@ -38,8 +39,8 @@ def get_start_method_input_and_output_types_annotations( warnings.warn( f"Expected decorated start method {start_method} to have type annotations" ) - return MISSING_TYPE, MISSING_TYPE - output_type = type_annotations.pop("return", MISSING_TYPE) + return Any, Any + output_type = type_annotations.pop("return", Any) if len(type_annotations) != 2: # TODO(preview): stacklevel @@ -48,7 +49,7 @@ def get_start_method_input_and_output_types_annotations( f"type-annotated parameters (ctx and input), but has {len(type_annotations)}: " f"{type_annotations}." ) - input_type = MISSING_TYPE + input_type = Any else: ctx_type, input_type = type_annotations.values() if not issubclass(ctx_type, StartOperationContext): @@ -57,7 +58,7 @@ def get_start_method_input_and_output_types_annotations( f"Expected first parameter of {start_method} to be an instance of " f"StartOperationContext, but is {ctx_type}." ) - input_type = MISSING_TYPE + input_type = Any return input_type, output_type diff --git a/src/nexusrpc/types.py b/src/nexusrpc/types.py index 5a0ce1c..dd9f300 100644 --- a/src/nexusrpc/types.py +++ b/src/nexusrpc/types.py @@ -13,14 +13,3 @@ # A user's service definition class, typically decorated with @service ServiceDefinitionT = TypeVar("ServiceDefinitionT") - - -class MISSING_TYPE: - """ - A missing input or output type. - - A sentinel type used to indicate an input or output type that is not specified by an - operation. - """ - - pass diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index f89b018..212bb50 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -12,7 +12,6 @@ import pytest from nexusrpc.handler import ( - MISSING_TYPE, StartOperationContext, get_start_method_input_and_output_types_annotations, ) @@ -28,7 +27,7 @@ class Output: class _TestCase: start: Callable - expected_types: tuple[Type[Any], Type[Any]] + expected_types: tuple[Any, Any] class SyncMethod(_TestCase): @@ -56,49 +55,49 @@ def start( self, ctx: StartOperationContext, i ) -> Union[Output, Awaitable[Output]]: ... - expected_types = (MISSING_TYPE, Union[Output, Awaitable[Output]]) + expected_types = (Any, Union[Output, Awaitable[Output]]) class TooFewParams(_TestCase): def start(self, i: Input) -> Output: ... - expected_types = (MISSING_TYPE, Output) + expected_types = (Any, Output) class TooManyParams(_TestCase): def start(self, ctx: StartOperationContext, i: Input, extra: int) -> Output: ... - expected_types = (MISSING_TYPE, Output) + expected_types = (Any, Output) class WrongOptionsType(_TestCase): def start(self, ctx: int, i: Input) -> Output: ... - expected_types = (MISSING_TYPE, Output) + expected_types = (Any, Output) class NoReturnHint(_TestCase): def start(self, ctx: StartOperationContext, i: Input): ... - expected_types = (Input, MISSING_TYPE) + expected_types = (Input, Any) class NoInputAnnotation(_TestCase): def start(self, ctx: StartOperationContext, i) -> Output: ... - expected_types = (MISSING_TYPE, Output) + expected_types = (Any, Output) class NoOptionsAnnotation(_TestCase): def start(self, ctx, i: Input) -> Output: ... - expected_types = (MISSING_TYPE, Output) + expected_types = (Any, Output) class AllAnnotationsMissing(_TestCase): def start(self, ctx: StartOperationContext, i: Input): ... - expected_types = (Input, MISSING_TYPE) + expected_types = (Input, Any) @pytest.mark.parametrize( From 696da1bfbe2d3337cb4433cf759828acb1254285 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 11 Jun 2025 08:06:57 -0400 Subject: [PATCH 012/178] Use None as the sentinel value --- src/nexusrpc/_service_definition.py | 8 ++-- src/nexusrpc/handler/_core.py | 12 ++++- src/nexusrpc/handler/_decorators.py | 4 +- src/nexusrpc/handler/_util.py | 13 ++--- ...ator_validates_against_service_contract.py | 47 ++++++++++++++++++- tests/test_get_input_and_output_types.py | 25 ++++++---- 6 files changed, 85 insertions(+), 24 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 467a113..e443d9d 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -51,8 +51,8 @@ class MyNexusService: name: str method_name: str = dataclasses.field(init=False) - input_type: Any = dataclasses.field(init=False) - output_type: Any = dataclasses.field(init=False) + input_type: Optional[Type[InputT]] = dataclasses.field(init=False) + output_type: Optional[Type[OutputT]] = dataclasses.field(init=False) @classmethod def _create( @@ -60,8 +60,8 @@ def _create( *, name: Optional[str] = None, method_name: str, - input_type: Any, - output_type: Any, + input_type: Optional[Type], + output_type: Optional[Type], ) -> Operation: op = cls(name or method_name) op.method_name = method_name diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 3824213..93aa453 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -448,12 +448,20 @@ def validate_operation_handler_methods( f":py:func:`@nexusrpc.handler.operation_handler` or " f":py:func:`@nexusrpc.handler.sync_operation_handler`?" ) - if op.input_type != op_defn.input_type and op.input_type != Any: + if ( + op.input_type is not None + and op_defn.input_type is not Any + and op.input_type != op_defn.input_type + ): raise TypeError( f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " f"which does not match the input type '{op_defn.input_type}' in interface '{service_definition}'." ) - if op.output_type != op_defn.output_type and op.output_type != Any: + if ( + op.output_type is not None + and op_defn.output_type is not Any + and op.output_type != op_defn.output_type + ): raise TypeError( f"Operation '{op_name}' in service '{user_service_cls}' has output type '{op.output_type}', " f"which does not match the output type '{op_defn.output_type}' in interface '{service_definition}'." diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 37e37ef..67862c4 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -183,8 +183,8 @@ def decorator( method: OperationHandlerFactoryT, ) -> OperationHandlerFactoryT: # Extract input and output types from the return type annotation - input_type = Any - output_type = Any + input_type = None + output_type = None return_type = typing.get_type_hints(method).get("return") if typing.get_origin(return_type) == OperationHandler: diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 6ce5e3e..02db250 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -8,6 +8,7 @@ Any, Awaitable, Callable, + Optional, Type, Union, ) @@ -25,8 +26,8 @@ def get_start_method_input_and_output_types_annotations( Union[OutputT, Awaitable[OutputT]], ], ) -> tuple[ - Union[Type[InputT], Any], - Union[Type[OutputT], Any], + Optional[Type[InputT]], + Optional[Type[OutputT]], ]: """Return operation input and output types. @@ -39,8 +40,8 @@ def get_start_method_input_and_output_types_annotations( warnings.warn( f"Expected decorated start method {start_method} to have type annotations" ) - return Any, Any - output_type = type_annotations.pop("return", Any) + return None, None + output_type = type_annotations.pop("return", None) if len(type_annotations) != 2: # TODO(preview): stacklevel @@ -49,7 +50,7 @@ def get_start_method_input_and_output_types_annotations( f"type-annotated parameters (ctx and input), but has {len(type_annotations)}: " f"{type_annotations}." ) - input_type = Any + input_type = None else: ctx_type, input_type = type_annotations.values() if not issubclass(ctx_type, StartOperationContext): @@ -58,7 +59,7 @@ def get_start_method_input_and_output_types_annotations( f"Expected first parameter of {start_method} to be an instance of " f"StartOperationContext, but is {ctx_type}." ) - input_type = Any + input_type = None return input_type, output_type 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 index a3d26e6..07dd476 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -1,4 +1,4 @@ -from typing import Optional, Type +from typing import Any, Optional, Type import pytest @@ -109,6 +109,48 @@ async def op( error_message = "does not match the output type" +class WrongOutputTypeWithNone(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[str, None] + + class Impl: + @nexusrpc.handler.sync_operation_handler + async def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: str + ) -> str: ... + + error_message = "does not match the output type" + + +class ValidImplWithNone(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[str, None] + + class Impl: + @nexusrpc.handler.sync_operation_handler + async def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: str + ) -> None: ... + + error_message = None + + +class MoreSpecificImplAllowed(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[Any, Any] + + class Impl: + @nexusrpc.handler.sync_operation_handler + async def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: str + ) -> str: ... + + error_message = None + + @pytest.mark.parametrize( "test_case", [ @@ -119,6 +161,9 @@ async def op( MissingInputAnnotation, MissingOptionsAnnotation, WrongOutputType, + WrongOutputTypeWithNone, + ValidImplWithNone, + MoreSpecificImplAllowed, ], ) def test_service_decorator_enforces_interface_implementation( diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index 212bb50..de4f837 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -55,49 +55,55 @@ def start( self, ctx: StartOperationContext, i ) -> Union[Output, Awaitable[Output]]: ... - expected_types = (Any, Union[Output, Awaitable[Output]]) + expected_types = (None, Union[Output, Awaitable[Output]]) class TooFewParams(_TestCase): def start(self, i: Input) -> Output: ... - expected_types = (Any, Output) + expected_types = (None, Output) class TooManyParams(_TestCase): def start(self, ctx: StartOperationContext, i: Input, extra: int) -> Output: ... - expected_types = (Any, Output) + expected_types = (None, Output) class WrongOptionsType(_TestCase): def start(self, ctx: int, i: Input) -> Output: ... - expected_types = (Any, Output) + expected_types = (None, Output) class NoReturnHint(_TestCase): def start(self, ctx: StartOperationContext, i: Input): ... - expected_types = (Input, Any) + expected_types = (Input, None) class NoInputAnnotation(_TestCase): def start(self, ctx: StartOperationContext, i) -> Output: ... - expected_types = (Any, Output) + expected_types = (None, Output) class NoOptionsAnnotation(_TestCase): def start(self, ctx, i: Input) -> Output: ... - expected_types = (Any, Output) + expected_types = (None, Output) class AllAnnotationsMissing(_TestCase): - def start(self, ctx: StartOperationContext, i: Input): ... + def start(self, ctx: StartOperationContext, i): ... + + expected_types = (None, None) + + +class ExplicitNoneTypes(_TestCase): + def start(self, ctx: StartOperationContext, i: None) -> None: ... - expected_types = (Input, Any) + expected_types = (type(None), type(None)) @pytest.mark.parametrize( @@ -114,6 +120,7 @@ def start(self, ctx: StartOperationContext, i: Input): ... NoOptionsAnnotation, MissingInputAnnotationInUnionMethod, AllAnnotationsMissing, + ExplicitNoneTypes, ], ) def test_get_input_and_output_types(test_case: Type[_TestCase]): From a75c8ee81c33ec9c56bae84059ad7e8bb1f4aedd Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 11 Jun 2025 09:34:18 -0400 Subject: [PATCH 013/178] Test output covariance --- ...ator_validates_against_service_contract.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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 index 07dd476..c4e7085 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -151,6 +151,60 @@ async def op( 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: + @nexusrpc.handler.sync_operation_handler + def op(self, ctx: nexusrpc.handler.StartOperationContext, input: X) -> X: ... + + error_message = None + + +class OutputCovarianceImplOutputCanBeSubclass(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[X, SuperClass] + + class Impl: + @nexusrpc.handler.sync_operation_handler + def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: X + ) -> Subclass: ... + + error_message = None + + +class OutputCovarianceImplOutputCannnotBeStrictSuperclass( + _InterfaceImplementationTestCase +): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[X, Subclass] + + class Impl: + @nexusrpc.handler.sync_operation_handler + def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: X + ) -> SuperClass: ... + + error_message = "is not compatible with the output type" + + @pytest.mark.parametrize( "test_case", [ @@ -164,6 +218,12 @@ async def op( WrongOutputTypeWithNone, ValidImplWithNone, MoreSpecificImplAllowed, + OutputCovarianceImplOutputCanBeSameType, + OutputCovarianceImplOutputCanBeSubclass, + OutputCovarianceImplOutputCannnotBeStrictSuperclass, + # ValidSubtyping, + # InvalidOutputSupertype, + # InvalidInputSubtype, ], ) def test_service_decorator_enforces_interface_implementation( From a029d06d2213aecfd55f3e8ddc13492a6e8d9479 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 11 Jun 2025 15:28:01 -0400 Subject: [PATCH 014/178] Make output covariance tests pass --- src/nexusrpc/handler/_core.py | 9 ++++++--- ...ndler_decorator_validates_against_service_contract.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 93aa453..49c15e9 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -457,14 +457,17 @@ def validate_operation_handler_methods( f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " f"which does not match the input type '{op_defn.input_type}' in interface '{service_definition}'." ) + # Output type is covariant: op handler output must be subclass of op defn output if ( op.output_type is not None - and op_defn.output_type is not Any - and op.output_type != op_defn.output_type + and op_defn.output_type is not None + and Any not in (op.output_type, op_defn.output_type) + and not issubclass(op.output_type, op_defn.output_type) ): raise TypeError( f"Operation '{op_name}' in service '{user_service_cls}' has output type '{op.output_type}', " - f"which does not match the output type '{op_defn.output_type}' in interface '{service_definition}'." + f"which is not compatible with the output type '{op_defn.output_type}' in interface '{service_definition}'. " + f"The output type must be the same as or a subclass of the operation definition output type." ) if service_definition.operations.keys() > user_methods.keys(): raise TypeError( 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 index c4e7085..bcfe357 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -106,7 +106,7 @@ async def op( self, ctx: nexusrpc.handler.StartOperationContext, input: None ) -> str: ... - error_message = "does not match the output type" + error_message = "is not compatible with the output type" class WrongOutputTypeWithNone(_InterfaceImplementationTestCase): @@ -120,7 +120,7 @@ async def op( self, ctx: nexusrpc.handler.StartOperationContext, input: str ) -> str: ... - error_message = "does not match the output type" + error_message = "is not compatible with the output type" class ValidImplWithNone(_InterfaceImplementationTestCase): From 090edff40b65dfcdd266252af685e8ff7b7d9551 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 11 Jun 2025 15:49:07 -0400 Subject: [PATCH 015/178] Test input contravariance --- ...ator_validates_against_service_contract.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) 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 index bcfe357..bbbcd74 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -92,7 +92,7 @@ async def op( self, ctx: nexusrpc.handler.StartOperationContext, input: str ) -> None: ... - error_message = "does not match the input type" + error_message = "is not compatible with the input type" class WrongOutputType(_InterfaceImplementationTestCase): @@ -205,6 +205,46 @@ def op( error_message = "is not compatible with the output type" +class InputContravarianceImplInputCanBeSameType(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[X, X] + + class Impl: + @nexusrpc.handler.sync_operation_handler + def op(self, ctx: nexusrpc.handler.StartOperationContext, input: X) -> X: ... + + error_message = None + + +class InputContravarianceImplInputCanBeSuperclass(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[Subclass, X] + + class Impl: + @nexusrpc.handler.sync_operation_handler + def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: SuperClass + ) -> X: ... + + error_message = None + + +class InputContravarianceImplInputCannotBeSubclass(_InterfaceImplementationTestCase): + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[SuperClass, X] + + class Impl: + @nexusrpc.handler.sync_operation_handler + def op( + self, ctx: nexusrpc.handler.StartOperationContext, input: Subclass + ) -> X: ... + + error_message = "is not compatible with the input type" + + @pytest.mark.parametrize( "test_case", [ @@ -221,6 +261,8 @@ def op( OutputCovarianceImplOutputCanBeSameType, OutputCovarianceImplOutputCanBeSubclass, OutputCovarianceImplOutputCannnotBeStrictSuperclass, + InputContravarianceImplInputCanBeSameType, + InputContravarianceImplInputCanBeSuperclass, # ValidSubtyping, # InvalidOutputSupertype, # InvalidInputSubtype, From ec039c19cfd2759c7a36932134b1e4c12bdc0871 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 11 Jun 2025 16:00:05 -0400 Subject: [PATCH 016/178] Make input contravariance tests pass --- src/nexusrpc/handler/_core.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 49c15e9..72a9715 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -448,14 +448,20 @@ def validate_operation_handler_methods( f":py:func:`@nexusrpc.handler.operation_handler` or " f":py:func:`@nexusrpc.handler.sync_operation_handler`?" ) + # Input type is contravariant: op handler input must be superclass of op defn output if ( op.input_type is not None - and op_defn.input_type is not Any - and op.input_type != op_defn.input_type + and op_defn.input_type is not None + and Any not in (op.input_type, op_defn.input_type) + and not ( + op_defn.input_type == op.input_type + or issubclass(op_defn.input_type, op.input_type) + ) ): raise TypeError( f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " - f"which does not match the input type '{op_defn.input_type}' in interface '{service_definition}'." + f"which is not compatible with the input type '{op_defn.input_type}' in interface '{service_definition}'. " + f"The input type must be the same as or a superclass of the operation definition input type." ) # Output type is covariant: op handler output must be subclass of op defn output if ( From 201ab950b992c5456d211200cc184f3fd415f194 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 11 Jun 2025 16:55:54 -0400 Subject: [PATCH 017/178] Fix bugs found by Cursor BugBot --- src/nexusrpc/handler/_core.py | 6 +++--- src/nexusrpc/handler/_decorators.py | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 72a9715..ad825da 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -176,14 +176,14 @@ async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> Non else: if not self.sync_executor: raise RuntimeError( - "Operation cancel handler method is async but " + "Operation cancel handler method is not an `async def` function but " "no executor was provided to the Handler constructor." ) result = await self.sync_executor.run_sync(op_handler.cancel, ctx, token) if inspect.isawaitable(result): raise RuntimeError( - f"Operation start handler method {op_handler.cancel} returned an " - "awaitable but is not an `async def` coroutine function." + f"Operation cancel handler method {op_handler.cancel} returned an " + "awaitable but is not an `async def` function." ) return result diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 67862c4..112fe94 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -20,6 +20,7 @@ from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._common import ( + CancelOperationContext, StartOperationContext, StartOperationResultSync, ) @@ -175,7 +176,7 @@ def my_operation(self) -> Operation[MyInput, MyOutput]: .. code-block:: python @nexusrpc.handler.operation_handler(name="my-operation") - defmy_operation(self) -> Operation[MyInput, MyOutput]: + def my_operation(self) -> Operation[MyInput, MyOutput]: ... """ @@ -303,7 +304,7 @@ async def start_async( op.start = types.MethodType(start_async, op) - async def cancel_async(_, ctx: StartOperationContext, token: str): + async def cancel_async(_, ctx: CancelOperationContext, token: str): raise NotImplementedError( "An operation that responded synchronously cannot be cancelled." ) @@ -325,7 +326,7 @@ def start( op.start = types.MethodType(start, op) - def cancel(_, ctx: StartOperationContext, token: str): + def cancel(_, ctx: CancelOperationContext, token: str): raise NotImplementedError( "An operation that responded synchronously cannot be cancelled." ) From 1d65a51ccac82078fe1731c4ec3a5a43f88fa5f8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 12 Jun 2025 19:54:04 -0400 Subject: [PATCH 018/178] Minor fixups --- src/nexusrpc/handler/_common.py | 5 +++-- src/nexusrpc/handler/_core.py | 5 +++-- src/nexusrpc/handler/_util.py | 9 +++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index a8a151a..e7da2b7 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -6,6 +6,7 @@ Generic, Mapping, Optional, + Sequence, ) from nexusrpc.types import OutputT @@ -114,14 +115,14 @@ class StartOperationContext(OperationContext): # asynchronous operation completes. callback_headers: Mapping[str, str] = field(default_factory=dict) - # Request ID that may be used by the server handler to dedupe a start request. + # Request ID that may be used by the handler to dedupe a start request. # By default a v4 UUID will be generated by the client. request_id: Optional[str] = None # 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. - inbound_links: list[Link] = field(default_factory=list) + inbound_links: Sequence[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 diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index ad825da..4bb5d6b 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -460,8 +460,9 @@ def validate_operation_handler_methods( ): raise TypeError( f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " - f"which is not compatible with the input type '{op_defn.input_type}' in interface '{service_definition}'. " - f"The input type must be the same as or a superclass of the operation definition input type." + f"which is not compatible with the input type '{op_defn.input_type}' " + f" in interface '{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 ( diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 02db250..f21caac 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -45,10 +45,11 @@ def get_start_method_input_and_output_types_annotations( if len(type_annotations) != 2: # TODO(preview): stacklevel + suffix = f": {type_annotations}" if type_annotations else "" warnings.warn( - f"Expected decorated start method {start_method} to have exactly two " - f"type-annotated parameters (ctx and input), but has {len(type_annotations)}: " - f"{type_annotations}." + f"Expected decorated start method {start_method} to have exactly 2 " + f"type-annotated parameters (ctx and input), but it has {len(type_annotations)}" + f"{suffix}." ) input_type = None else: @@ -68,7 +69,7 @@ def get_start_method_input_and_output_types_annotations( # # Copyright (c) 2024 Anthropic, PBC. # -# Modified to use TypeIs. +# Modified to use TypeGuard. # # This file is licensed under the MIT License. def is_async_callable(obj: Any) -> TypeGuard[Callable[..., Awaitable[Any]]]: From c71f0f072659be9bc85b348cd8b390b179ccb015 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Fri, 13 Jun 2025 06:49:01 -0400 Subject: [PATCH 019/178] Move http client to Temporal SDK --- src/nexusrpc/testing/client.py | 63 ---------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 src/nexusrpc/testing/client.py diff --git a/src/nexusrpc/testing/client.py b/src/nexusrpc/testing/client.py deleted file mode 100644 index 724ab8e..0000000 --- a/src/nexusrpc/testing/client.py +++ /dev/null @@ -1,63 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Mapping - -import httpx - - -@dataclass -class ServiceClient: - server_address: str # E.g. http://127.0.0.1:7243 - endpoint: str - service: str - - async def start_operation( - self, - operation: str, - body: dict[str, Any], - headers: Mapping[str, str] = {}, - ) -> httpx.Response: - """ - Start a Nexus operation. - """ - async with httpx.AsyncClient() as http_client: - return await http_client.post( - f"{self.server_address}/nexus/endpoints/{self.endpoint}/services/{self.service}/{operation}", - json=body, - headers=headers, - ) - - async def fetch_operation_info( - self, - operation: str, - token: str, - ) -> httpx.Response: - async with httpx.AsyncClient() as http_client: - return await http_client.get( - f"{self.server_address}/nexus/endpoints/{self.endpoint}/services/{self.service}/{operation}", - # Token can also be sent as "Nexus-Operation-Token" header - params={"token": token}, - ) - - async def fetch_operation_result( - self, - operation: str, - token: str, - ) -> httpx.Response: - async with httpx.AsyncClient() as http_client: - return await http_client.get( - f"{self.server_address}/nexus/endpoints/{self.endpoint}/services/{self.service}/{operation}/result", - # Token can also be sent as "Nexus-Operation-Token" header - params={"token": token}, - ) - - async def cancel_operation( - self, - operation: str, - token: str, - ) -> httpx.Response: - async with httpx.AsyncClient() as http_client: - return await http_client.post( - f"{self.server_address}/nexus/endpoints/{self.endpoint}/services/{self.service}/{operation}/cancel", - # Token can also be sent as "Nexus-Operation-Token" header - params={"token": token}, - ) From 48ad60e01ecf1d768810002d30d68d53e9c67ca8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 14 Jun 2025 19:32:16 -0400 Subject: [PATCH 020/178] Do not require cause for HandlerError --- src/nexusrpc/handler/__init__.py | 6 ------ src/nexusrpc/handler/_common.py | 20 ++++++++++++++++---- src/nexusrpc/handler/_core.py | 26 ++++++++------------------ 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 0e00b25..6c9227b 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -58,12 +58,6 @@ from ._core import ( SyncOperationHandler as SyncOperationHandler, ) -from ._core import ( - UnknownOperationError as UnknownOperationError, -) -from ._core import ( - UnknownServiceError as UnknownServiceError, -) from ._decorators import ( operation_handler as operation_handler, ) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index e7da2b7..bebffd2 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -32,24 +32,36 @@ class HandlerErrorType(Enum): class HandlerError(Exception): """ A Nexus handler error. - """ - __cause__: BaseException + 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. + """ def __init__( self, message: str, *, type: HandlerErrorType, - cause: BaseException, + cause: Optional[BaseException] = None, # Whether this error should be considered retryable. If not specified, retry # behavior is determined from the error type. For example, INTERNAL is retryable # by default unless specified otherwise. retryable: Optional[bool] = None, ): + """ + Initializes a new HandlerError. + + :param message: A descriptive message for the error. This will become the `message` + in the resulting Nexus Failure object. + :param type: The type of handler error. + :param cause: The original exception that caused this handler error, if any. + This will be encoded in the `details` of the Nexus Failure object. + :param retryable: Whether this error should be retried. If not + provided, the default behavior for the error type is used. + """ super().__init__(message) - self.type = type self.__cause__ = cause + self.type = type self.retryable = retryable diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 4bb5d6b..f8e5623 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -29,6 +29,8 @@ CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, + HandlerError, + HandlerErrorType, OperationInfo, StartOperationContext, StartOperationResultAsync, @@ -37,18 +39,6 @@ from ._serializer import LazyValue -class UnknownServiceError(RuntimeError): - """Raised when a request contains a service name that does not match a service handler.""" - - pass - - -class UnknownOperationError(RuntimeError): - """Raised when a request contains an operation name that does not match an operation handler.""" - - pass - - @dataclass class Handler: """ @@ -191,10 +181,10 @@ 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: - # TODO(prerelease): can this raise HandlerError directly or is HandlerError always a - # wrapper? I have currently made its __cause__ required but if it's not a - # wrapper then that is wrong. - raise UnknownServiceError(f"No handler for service '{service_name}'.") + raise HandlerError( + f"No handler for service '{service_name}'.", + type=HandlerErrorType.NOT_FOUND, + ) return service @@ -251,7 +241,7 @@ def _get_operation_handler(self, operation: str) -> OperationHandler[Any, Any]: if self.service.operations: msg += f": {', '.join(sorted(self.service.operations.keys()))}" msg += "." - raise UnknownOperationError(msg) + raise HandlerError(msg, type=HandlerErrorType.NOT_FOUND) operation_handler = self.operation_handlers.get(operation) if operation_handler is None: # This should not be possible. If a service definition was supplied then @@ -264,7 +254,7 @@ def _get_operation_handler(self, operation: str) -> OperationHandler[Any, Any]: if self.operation_handlers: msg += f": {', '.join(sorted(self.operation_handlers.keys()))}" msg += "." - raise UnknownOperationError(msg) + raise HandlerError(msg, type=HandlerErrorType.NOT_FOUND) return operation_handler From 87c4adbb6f863d7e6f4d97e347dbe97c99b1d41a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 08:48:39 -0400 Subject: [PATCH 021/178] Clean-up AI-authored test --- ..._service_handler_decorator_requirements.py | 114 +++++++----------- 1 file changed, 41 insertions(+), 73 deletions(-) diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 6feb5e2..3e13983 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -14,38 +14,30 @@ # temporalio.common._type_hints_from_func(hello_nexus.hello2().fetch_result), -# Test Case for Decorator Validation class _DecoratorValidationTestCase: - UserServiceDefinition: Type[Any] + UserService: Type[Any] UserServiceHandler: Type[Any] expected_error_message_pattern: str class MissingOperationFromDefinition(_DecoratorValidationTestCase): @nexusrpc.service - class ServiceDefinition: + class UserService: op_A: nexusrpc.Operation[int, str] op_B: nexusrpc.Operation[bool, float] - UserServiceDefinition = ServiceDefinition - - class HandlerMissingOpB: + class UserServiceHandler: @nexusrpc.handler.operation_handler def op_A(self) -> nexusrpc.handler.OperationHandler[int, str]: ... - # op_B is missing - - UserServiceHandler = HandlerMissingOpB expected_error_message_pattern = r"does not implement operation 'op_B'" class MethodNameDoesNotMatchDefinition(_DecoratorValidationTestCase): @nexusrpc.service - class ServiceDefinition: + class UserService: op_A: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="foo") - UserServiceDefinition = ServiceDefinition - class UserServiceHandler: @nexusrpc.handler.operation_handler def op_A_incorrect_method_name( @@ -68,105 +60,86 @@ def test_decorator_validates_definition_compliance( test_case: _DecoratorValidationTestCase, ): with pytest.raises(TypeError, match=test_case.expected_error_message_pattern): - nexusrpc.handler.service_handler(service=test_case.UserServiceDefinition)( + nexusrpc.handler.service_handler(service=test_case.UserService)( test_case.UserServiceHandler ) -# Test Cases for Service Implementation Inheritance -class _ServiceImplInheritanceTestCase: - test_case_name: str - BaseImpl: Type[Any] - ChildImpl: Type[Any] - expected_operations_in_child_handler: set[str] - +class _ServiceHandlerInheritanceTestCase: + UserServiceHandler: Type[Any] + expected_operations: set[str] -class ServiceImplInheritanceWithDefinition(_ServiceImplInheritanceTestCase): - test_case_name = "ServiceImplInheritanceWithContracts" +class ServiceHandlerInheritanceWithServiceDefinition( + _ServiceHandlerInheritanceTestCase +): @nexusrpc.service - class ContractA: + class BaseUserService: base_op: nexusrpc.Operation[int, str] @nexusrpc.service - class ContractB: + class UserService: base_op: nexusrpc.Operation[int, str] child_op: nexusrpc.Operation[bool, float] - @nexusrpc.handler.service_handler(service=ContractA) - class AImplementation: + @nexusrpc.handler.service_handler(service=BaseUserService) + class BaseUserServiceHandler: @nexusrpc.handler.operation_handler def base_op(self) -> nexusrpc.handler.OperationHandler[int, str]: ... - @nexusrpc.handler.service_handler(service=ContractB) - class BImplementation(AImplementation): + @nexusrpc.handler.service_handler(service=UserService) + class UserServiceHandler(BaseUserServiceHandler): @nexusrpc.handler.operation_handler def child_op(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... - BaseImpl = AImplementation - ChildImpl = BImplementation - expected_operations_in_child_handler = {"base_op", "child_op"} + expected_operations = {"base_op", "child_op"} -class ServiceImplInheritanceWithoutDefinition(_ServiceImplInheritanceTestCase): - test_case_name = "ServiceImplInheritanceWithoutDefinition" - +class ServiceHandlerInheritanceWithoutDefinition(_ServiceHandlerInheritanceTestCase): @nexusrpc.handler.service_handler - class BaseImplWithoutDefinition: + class BaseUserServiceHandler: @nexusrpc.handler.operation_handler def base_op_nc(self) -> nexusrpc.handler.OperationHandler[int, str]: ... @nexusrpc.handler.service_handler - class ChildImplWithoutDefinition(BaseImplWithoutDefinition): + class UserServiceHandler(BaseUserServiceHandler): @nexusrpc.handler.operation_handler def child_op_nc(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... - BaseImpl = BaseImplWithoutDefinition - ChildImpl = ChildImplWithoutDefinition - expected_operations_in_child_handler = {"base_op_nc", "child_op_nc"} + expected_operations = {"base_op_nc", "child_op_nc"} @pytest.mark.parametrize( "test_case", [ - ServiceImplInheritanceWithDefinition, - ServiceImplInheritanceWithoutDefinition, + ServiceHandlerInheritanceWithServiceDefinition, + ServiceHandlerInheritanceWithoutDefinition, ], ) -def test_service_implementation_inheritance(test_case: _ServiceImplInheritanceTestCase): - child_instance = test_case.ChildImpl() - service_handler_meta = ServiceHandler.from_user_instance(child_instance) +def test_service_implementation_inheritance( + test_case: _ServiceHandlerInheritanceTestCase, +): + service_handler = ServiceHandler.from_user_instance(test_case.UserServiceHandler()) - assert ( - set(service_handler_meta.operation_handlers.keys()) - == test_case.expected_operations_in_child_handler - ) - assert ( - set(service_handler_meta.service.operations.keys()) - == test_case.expected_operations_in_child_handler - ) + assert set(service_handler.operation_handlers) == test_case.expected_operations + assert set(service_handler.service.operations) == test_case.expected_operations -# Test Cases for Service Definition Inheritance (Current Behavior due to TODO) class _ServiceDefinitionInheritanceTestCase: - test_case_name: str - ChildDefinitionInheriting: Type[Any] # We only need to inspect the child definition - expected_ops_in_child_definition: set[str] + UserService: Type[Any] + expected_ops: set[str] class ServiceDefinitionInheritance(_ServiceDefinitionInheritanceTestCase): - test_case_name = "ServiceDefinitionInheritance" - @nexusrpc.service - class BaseDef: + class BaseUserService: op_from_base_definition: nexusrpc.Operation[int, str] @nexusrpc.service - class ChildDefInherits(BaseDef): + class UserService(BaseUserService): op_from_child_definition: nexusrpc.Operation[bool, float] - ChildDefinitionInheriting = ChildDefInherits - expected_ops_in_child_definition = { + expected_ops = { "op_from_base_definition", "op_from_child_definition", } @@ -184,27 +157,22 @@ class ChildDefInherits(BaseDef): def test_service_definition_inheritance_behavior( test_case: _ServiceDefinitionInheritanceTestCase, ): - child_service_definition = getattr( - test_case.ChildDefinitionInheriting, "__nexus_service__", None - ) + service_defn = getattr(test_case.UserService, "__nexus_service__", None) - assert child_service_definition is not None, ( - f"{test_case.ChildDefinitionInheriting.__name__} lacks __nexus_service__ attribute." + assert service_defn is not None, ( + f"{test_case.UserService.__name__} lacks __nexus_service__ attribute." ) - assert isinstance(child_service_definition, nexusrpc.ServiceDefinition), ( + assert isinstance(service_defn, nexusrpc.ServiceDefinition), ( "__nexus_service__ is not a nexusrpc.ServiceDefinition instance." ) - assert ( - set(child_service_definition.operations.keys()) - == test_case.expected_ops_in_child_definition - ) + assert set(service_defn.operations) == test_case.expected_ops with pytest.raises( TypeError, match="does not implement operation 'op_from_base_definition'" ): - @nexusrpc.handler.service_handler(service=test_case.ChildDefinitionInheriting) + @nexusrpc.handler.service_handler(service=test_case.UserService) class HandlerMissingChildOp: @nexusrpc.handler.operation_handler def op_from_base_definition( From 79eb6a14542ff92f2e13968e93ca48a7ed7fec92 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 08:54:30 -0400 Subject: [PATCH 022/178] Failg test for service definition inheritance --- tests/handler/test_service_handler_decorator_requirements.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 3e13983..7490530 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -151,9 +151,6 @@ class UserService(BaseUserService): ServiceDefinitionInheritance, ], ) -@pytest.mark.skip( - reason="TODO(prerelease): service definition inheritance is not supported yet" -) def test_service_definition_inheritance_behavior( test_case: _ServiceDefinitionInheritanceTestCase, ): From 91855dab98840a1d7f6f5203e1b11d90d9ba22fb Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 10:51:24 -0400 Subject: [PATCH 023/178] Add tests of service defn inheritance --- src/nexusrpc/_service_definition.py | 1 + .../test_service_definition_inheritance.py | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/service_definition/test_service_definition_inheritance.py diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index e443d9d..1ed405b 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -118,6 +118,7 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: # and __dict__ operations: dict[str, Operation] = {} + # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older annotations: dict[str, Any] = getattr(cls, "__annotations__", {}) for annot_name, op in annotations.items(): if typing.get_origin(op) == Operation: 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..220da6c --- /dev/null +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -0,0 +1,97 @@ +import sys +from pprint import pprint +from typing import Generic, TypeVar + +import pytest + +from nexusrpc import ServiceDefinition, service + +InputT = TypeVar("InputT") +OutputT = TypeVar("OutputT") + + +class Operation(Generic[InputT, OutputT]): + pass + + +@service +class A1: + a: Operation[int, int] + + +@service +class A2(A1): + b: Operation[int, int] + + +print(sys.version, end="\n\n") +print("A2: child class with class attribute type annotations only") +print("\n__annotations__") +pprint(A2.__annotations__) +print("\n__dict__") +pprint(A2.__dict__) + + +# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older + + +@service +class B1: + a: Operation[int, int] = Operation[int, int]() + + +@service +class B2(B1): + b: Operation[int, int] = Operation[int, int]() + + +print("\n\nB2: child class with class attribute type annotations with values") +print("\n__annotations__") +pprint(B2.__annotations__) +print("\n__dict__") +pprint(B2.__dict__) + + +@service +class C1: + a: Operation[int, int] = Operation[int, int]() + b: Operation[int, int] = Operation[int, int]() + + +@service +class C2(C1): + pass + + +print("\n\nC2: child class with class attribute type annotations with values") +print("\n__annotations__") +pprint(C2.__annotations__) +print("\n__dict__") +pprint(C2.__dict__) + + +# ops = {name: nexusrpc.Operation[int, int] for name in op_names} +# service_cls = nexusrpc.service(type("ServiceContract", (), ops)) + + +@service +class D1: + a: Operation[int, int] + + +d2_ops = {name: Operation[int, int] for name in ["b"]} + +D2 = service(type("D2", (D1,), d2_ops)) + +print("\n\nD2: child class synthesized from class attribute type annotations only") +print("\n__annotations__") +pprint(D2.__annotations__) +print("\n__dict__") +pprint(D2.__dict__) + + +@pytest.mark.parametrize("user_service", [A2, B2, C2, D2]) +def test_user_service_definition_inheritance(user_service): + service_defn = getattr(user_service, "__nexus_service__", None) + assert isinstance(service_defn, ServiceDefinition) + assert set(service_defn.operations) == {"a", "b"} From dbcc00906f026770a1382b8e68054cfdfd6554f3 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 12:07:23 -0400 Subject: [PATCH 024/178] Clean up test --- .../test_service_definition_inheritance.py | 64 ++++--------------- 1 file changed, 13 insertions(+), 51 deletions(-) diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 220da6c..a356975 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -1,97 +1,59 @@ -import sys from pprint import pprint -from typing import Generic, TypeVar import pytest -from nexusrpc import ServiceDefinition, service +from nexusrpc import Operation, ServiceDefinition, service -InputT = TypeVar("InputT") -OutputT = TypeVar("OutputT") - -class Operation(Generic[InputT, OutputT]): - pass - - -@service class A1: a: Operation[int, int] -@service class A2(A1): b: Operation[int, int] -print(sys.version, end="\n\n") -print("A2: child class with class attribute type annotations only") -print("\n__annotations__") -pprint(A2.__annotations__) -print("\n__dict__") -pprint(A2.__dict__) - - # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older -@service class B1: - a: Operation[int, int] = Operation[int, int]() + a: Operation[int, int] = Operation[int, int](name="a-name") -@service class B2(B1): - b: Operation[int, int] = Operation[int, int]() + b: Operation[int, int] = Operation[int, int](name="b-name") -print("\n\nB2: child class with class attribute type annotations with values") -print("\n__annotations__") -pprint(B2.__annotations__) -print("\n__dict__") -pprint(B2.__dict__) - - -@service class C1: - a: Operation[int, int] = Operation[int, int]() - b: Operation[int, int] = Operation[int, int]() + a: Operation[int, int] = Operation[int, int](name="a-name") + b: Operation[int, int] = Operation[int, int](name="b-name") -@service class C2(C1): pass -print("\n\nC2: child class with class attribute type annotations with values") -print("\n__annotations__") -pprint(C2.__annotations__) -print("\n__dict__") -pprint(C2.__dict__) - - # ops = {name: nexusrpc.Operation[int, int] for name in op_names} # service_cls = nexusrpc.service(type("ServiceContract", (), ops)) -@service class D1: a: Operation[int, int] d2_ops = {name: Operation[int, int] for name in ["b"]} -D2 = service(type("D2", (D1,), d2_ops)) - -print("\n\nD2: child class synthesized from class attribute type annotations only") -print("\n__annotations__") -pprint(D2.__annotations__) -print("\n__dict__") -pprint(D2.__dict__) +D2 = type("D2", (D1,), d2_ops) @pytest.mark.parametrize("user_service", [A2, B2, C2, D2]) def test_user_service_definition_inheritance(user_service): - service_defn = getattr(user_service, "__nexus_service__", None) + print(f"\n\n{user_service.__name__}:") + print("\n__annotations__") + pprint(user_service.__annotations__) + print("\n__dict__") + pprint(user_service.__dict__) + + service_defn = getattr(service(user_service), "__nexus_service__", None) assert isinstance(service_defn, ServiceDefinition) assert set(service_defn.operations) == {"a", "b"} From 79d73e0d8cc7cc11e8151689108123714b8fb4a9 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 13:15:18 -0400 Subject: [PATCH 025/178] Clean up test --- .../test_service_definition_inheritance.py | 81 +++++++++++++------ 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index a356975..2326668 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -1,59 +1,88 @@ from pprint import pprint +from typing import Any, Type import pytest from nexusrpc import Operation, ServiceDefinition, service -class A1: - a: Operation[int, int] +class _TestCase: + UserService: Type[Any] + expected_operation_names: set[str] -class A2(A1): - b: Operation[int, int] +class TypeAnnotationsOnly: + class A1: + a: Operation[int, int] + + class A2(A1): + b: Operation[int, int] + + UserService = A2 + expected_operation_names = {"a", "b"} # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older -class B1: - a: Operation[int, int] = Operation[int, int](name="a-name") +class TypeAnnotationsWithValues: + class A1: + a: Operation[int, int] = Operation[int, int](name="a-name") + class A2(A1): + b: Operation[int, int] = Operation[int, int](name="b-name") -class B2(B1): - b: Operation[int, int] = Operation[int, int](name="b-name") + UserService = A2 + expected_operation_names = {"a-name", "b-name"} -class C1: - a: Operation[int, int] = Operation[int, int](name="a-name") - b: Operation[int, int] = Operation[int, int](name="b-name") +class TypeAnnotationsWithValuesAllFromParentClass: + class A1: + a: Operation[int, int] = Operation[int, int](name="a-name") + b: Operation[int, int] = Operation[int, int](name="b-name") + class A2(A1): + pass -class C2(C1): - pass + UserService = A2 + expected_operation_names = {"a-name", "b-name"} -# ops = {name: nexusrpc.Operation[int, int] for name in op_names} -# service_cls = nexusrpc.service(type("ServiceContract", (), ops)) +class TypeValuesOnly: + class A1: + a = Operation[int, int] + UserService = A1 + expected_operation_names = {"a"} -class D1: - a: Operation[int, int] +class ChildClassSynthesizedWithTypeValues: + class A1: + a: Operation[int, int] -d2_ops = {name: Operation[int, int] for name in ["b"]} + A2 = type("A2", (A1,), {name: Operation[int, int] for name in ["b"]}) -D2 = type("D2", (D1,), d2_ops) + UserService = A2 + expected_operation_names = {"a", "b"} -@pytest.mark.parametrize("user_service", [A2, B2, C2, D2]) -def test_user_service_definition_inheritance(user_service): - print(f"\n\n{user_service.__name__}:") +@pytest.mark.parametrize( + "test_case", + [ + TypeAnnotationsOnly, + TypeAnnotationsWithValues, + TypeAnnotationsWithValuesAllFromParentClass, + TypeValuesOnly, + ChildClassSynthesizedWithTypeValues, + ], +) +def test_user_service_definition_inheritance(test_case: Type[_TestCase]): + print(f"\n\n{test_case.UserService.__name__}:") print("\n__annotations__") - pprint(user_service.__annotations__) + pprint(test_case.UserService.__annotations__) print("\n__dict__") - pprint(user_service.__dict__) + pprint(test_case.UserService.__dict__) - service_defn = getattr(service(user_service), "__nexus_service__", None) + service_defn = getattr(service(test_case.UserService), "__nexus_service__", None) assert isinstance(service_defn, ServiceDefinition) - assert set(service_defn.operations) == {"a", "b"} + assert set(service_defn.operations) == test_case.expected_operation_names From ce33dd4f57de405e0939ff1c0e48a1b60a8edfd5 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 11:03:29 -0400 Subject: [PATCH 026/178] service decorator: refactor --- src/nexusrpc/_service_definition.py | 78 ++++++++++++++++------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 1ed405b..b33f097 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -13,6 +13,7 @@ Any, Callable, Generic, + Iterator, Optional, Type, Union, @@ -118,41 +119,13 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: # and __dict__ operations: dict[str, Operation] = {} - # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older - annotations: dict[str, Any] = getattr(cls, "__annotations__", {}) - for annot_name, op in annotations.items(): - if typing.get_origin(op) == Operation: - args = typing.get_args(op) - if len(args) != 2: - raise TypeError( - f"Each operation in the service definition should look like " - f"nexusrpc.Operation[MyInputType, MyOutputType]. " - f"However, '{annot_name}' in '{cls}' has {len(args)} type parameters." - ) - input_type, output_type = args - op = getattr(cls, annot_name, None) - if not op: - op = Operation._create( - method_name=annot_name, - input_type=input_type, - output_type=output_type, - ) - setattr(cls, annot_name, op) - else: - if not isinstance(op, Operation): - raise TypeError( - f"Operation {annot_name} must be an instance of nexusrpc.Operation, " - f"but it is a {type(op)}" - ) - op.method_name = annot_name - op.input_type = input_type - op.output_type = output_type - - if op.name in operations: - raise ValueError( - f"Operation {op.name} in service {service_name} is defined multiple times" - ) - operations[op.name] = op + + for op in _operations_from_annotations(cls): + if op.name in operations: + raise ValueError( + f"Operation {op.name} in service {service_name} is defined multiple times" + ) + operations[op.name] = op cls.__nexus_service__ = ServiceDefinition( # type: ignore name=service_name, @@ -165,3 +138,38 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: return decorator else: return decorator(cls) + + +def _operations_from_annotations(cls) -> Iterator[Operation]: + # TODO(preview): backport inspect.get_annotations + # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older + annotations: dict[str, Any] = getattr(cls, "__annotations__", {}) + for annot_name, op in annotations.items(): + if typing.get_origin(op) == Operation: + args = typing.get_args(op) + if len(args) != 2: + raise TypeError( + f"Each operation in the service definition should look like " + f"nexusrpc.Operation[MyInputType, MyOutputType]. " + f"However, '{annot_name}' in '{cls}' has {len(args)} type parameters." + ) + input_type, output_type = args + op = getattr(cls, annot_name, None) + if not op: + op = Operation._create( + method_name=annot_name, + input_type=input_type, + output_type=output_type, + ) + setattr(cls, annot_name, op) + else: + if not isinstance(op, Operation): + raise TypeError( + f"Operation {annot_name} must be an instance of nexusrpc.Operation, " + f"but it is a {type(op)}" + ) + op.method_name = annot_name + op.input_type = input_type + op.output_type = output_type + + yield op From a63024a11a610f8847ce0cc620907043584e9330 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 12:07:33 -0400 Subject: [PATCH 027/178] service decorator: refactor --- src/nexusrpc/_service_definition.py | 61 +++++++++++++++-------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index b33f097..c737c49 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -141,35 +141,36 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: def _operations_from_annotations(cls) -> Iterator[Operation]: - # TODO(preview): backport inspect.get_annotations - # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older - annotations: dict[str, Any] = getattr(cls, "__annotations__", {}) - for annot_name, op in annotations.items(): - if typing.get_origin(op) == Operation: - args = typing.get_args(op) - if len(args) != 2: - raise TypeError( - f"Each operation in the service definition should look like " - f"nexusrpc.Operation[MyInputType, MyOutputType]. " - f"However, '{annot_name}' in '{cls}' has {len(args)} type parameters." - ) - input_type, output_type = args - op = getattr(cls, annot_name, None) - if not op: - op = Operation._create( - method_name=annot_name, - input_type=input_type, - output_type=output_type, - ) - setattr(cls, annot_name, op) - else: - if not isinstance(op, Operation): + for parent_cls in reversed(cls.mro()): + # TODO(preview): backport inspect.get_annotations + # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older + annotations: dict[str, Any] = getattr(parent_cls, "__annotations__", {}) + for annot_name, op in annotations.items(): + if typing.get_origin(op) == Operation: + args = typing.get_args(op) + if len(args) != 2: raise TypeError( - f"Operation {annot_name} must be an instance of nexusrpc.Operation, " - f"but it is a {type(op)}" + f"Each operation in the service definition should look like " + f"nexusrpc.Operation[MyInputType, MyOutputType]. " + f"However, '{annot_name}' in '{cls}' has {len(args)} type parameters." ) - op.method_name = annot_name - op.input_type = input_type - op.output_type = output_type - - yield op + input_type, output_type = args + op = getattr(cls, annot_name, None) + if not op: + op = Operation._create( + method_name=annot_name, + input_type=input_type, + output_type=output_type, + ) + setattr(cls, annot_name, op) + else: + if not isinstance(op, Operation): + raise TypeError( + f"Operation {annot_name} must be an instance of nexusrpc.Operation, " + f"but it is a {type(op)}" + ) + op.method_name = annot_name + op.input_type = input_type + op.output_type = output_type + + yield op From c92d4c27f82a299c3d743e48d608e19d6411f140 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 22:53:07 -0400 Subject: [PATCH 028/178] Cleanup --- src/nexusrpc/_service_definition.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index c737c49..2afaa2b 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -36,10 +36,10 @@ class ServiceDefinition: @dataclass class Operation(Generic[InputT, OutputT]): - """ - Used to define a Nexus operation in a Nexus service definition. + """Defines a Nexus operation in a Nexus service definition. - To implement an operation handler, see `:py:meth:nexusrpc.handler.operation_handler`. + 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: From abb05ccefdb995e747cc38e125f84b6c43b8152f Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 14:01:08 -0400 Subject: [PATCH 029/178] Use default Operation constructor --- src/nexusrpc/_service_definition.py | 28 ++++++------------- src/nexusrpc/handler/_decorators.py | 8 +++--- ...collects_expected_operation_definitions.py | 17 ++++++----- ..._service_handler_decorator_requirements.py | 2 +- .../test_service_decorator_validation.py | 2 +- 5 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 2afaa2b..300bee5 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -51,24 +51,9 @@ class MyNexusService: """ name: str - method_name: str = dataclasses.field(init=False) - input_type: Optional[Type[InputT]] = dataclasses.field(init=False) - output_type: Optional[Type[OutputT]] = dataclasses.field(init=False) - - @classmethod - def _create( - cls, - *, - name: Optional[str] = None, - method_name: str, - input_type: Optional[Type], - output_type: Optional[Type], - ) -> Operation: - op = cls(name or method_name) - op.method_name = method_name - op.input_type = input_type - op.output_type = output_type - return op + 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) @overload @@ -123,7 +108,7 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: for op in _operations_from_annotations(cls): if op.name in operations: raise ValueError( - f"Operation {op.name} in service {service_name} is defined multiple times" + f"Operation '{op.name}' in service '{service_name}' is defined multiple times" ) operations[op.name] = op @@ -142,10 +127,12 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: def _operations_from_annotations(cls) -> Iterator[Operation]: for parent_cls in reversed(cls.mro()): + print(f"🟠 parent_cls: {parent_cls.__name__}") # TODO(preview): backport inspect.get_annotations # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older annotations: dict[str, Any] = getattr(parent_cls, "__annotations__", {}) for annot_name, op in annotations.items(): + print(f"🟡 annot_name: {annot_name}") if typing.get_origin(op) == Operation: args = typing.get_args(op) if len(args) != 2: @@ -157,7 +144,8 @@ def _operations_from_annotations(cls) -> Iterator[Operation]: input_type, output_type = args op = getattr(cls, annot_name, None) if not op: - op = Operation._create( + op = Operation( + name=annot_name, method_name=annot_name, input_type=input_type, output_type=output_type, diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 112fe94..f13daa0 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -198,8 +198,8 @@ def decorator( f"but operation {method.__name__} has {len(type_args)} type parameters: {type_args}" ) - method.__nexus_operation__ = nexusrpc.Operation._create( - name=name, + method.__nexus_operation__ = nexusrpc.Operation( + name=name or method.__name__, method_name=method.__name__, input_type=input_type, output_type=output_type, @@ -350,8 +350,8 @@ def cancel(_, ctx: CancelOperationContext, token: str): f"expected {start_method} to be a function or callable instance." ) - factory.__nexus_operation__ = nexusrpc.Operation._create( - name=name, + factory.__nexus_operation__ = nexusrpc.Operation( + name=name or method_name, method_name=method_name, input_type=input_type, output_type=output_type, 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 index 94abf86..afca58b 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -36,7 +36,8 @@ class Service: def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { - "operation": nexusrpc.Operation._create( + "operation": nexusrpc.Operation( + name="operation", method_name="operation", input_type=Input, output_type=Output, @@ -51,7 +52,7 @@ class Service: def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { - "operation": nexusrpc.Operation._create( + "operation": nexusrpc.Operation( name="operation-name", method_name="operation", input_type=Input, @@ -69,7 +70,8 @@ def sync_operation_handler( ) -> Output: ... expected_operations = { - "sync_operation_handler": nexusrpc.Operation._create( + "sync_operation_handler": nexusrpc.Operation( + name="sync_operation_handler", method_name="sync_operation_handler", input_type=Input, output_type=Output, @@ -86,7 +88,7 @@ async def sync_operation_handler( ) -> Output: ... expected_operations = { - "sync_operation_handler": nexusrpc.Operation._create( + "sync_operation_handler": nexusrpc.Operation( name="sync-operation-name", method_name="sync_operation_handler", input_type=Input, @@ -106,7 +108,8 @@ class Service: def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { - "operation": nexusrpc.Operation._create( + "operation": nexusrpc.Operation( + name="operation", method_name="operation", input_type=Input, output_type=Output, @@ -127,7 +130,7 @@ class Service: def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { - "operation": nexusrpc.Operation._create( + "operation": nexusrpc.Operation( name="operation-override", method_name="operation", input_type=Input, @@ -159,7 +162,7 @@ def __call__( ) expected_operations = { - "sync_operation_with_callable_instance": nexusrpc.Operation._create( + "sync_operation_with_callable_instance": nexusrpc.Operation( name="sync_operation_with_callable_instance", method_name="CallableInstanceStartMethod", input_type=Input, diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 7490530..fadb93d 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -166,7 +166,7 @@ def test_service_definition_inheritance_behavior( assert set(service_defn.operations) == test_case.expected_ops with pytest.raises( - TypeError, match="does not implement operation 'op_from_base_definition'" + TypeError, match="does not implement operation 'op_from_child_definition'" ): @nexusrpc.handler.service_handler(service=test_case.UserService) diff --git a/tests/service_definition/test_service_decorator_validation.py b/tests/service_definition/test_service_decorator_validation.py index a5667ca..5005c40 100644 --- a/tests/service_definition/test_service_decorator_validation.py +++ b/tests/service_definition/test_service_decorator_validation.py @@ -23,7 +23,7 @@ class Contract: b: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="a") expected_error = ValueError( - "Operation a in service Contract is defined multiple times" + "Operation 'a' in service 'Contract' is defined multiple times" ) From 23b3fa6a2cde4b5e07924bf4531592d8c0564db4 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 14:26:12 -0400 Subject: [PATCH 030/178] Use get_annotations shim --- src/nexusrpc/_service_definition.py | 5 ++--- src/nexusrpc/_util.py | 15 +++++++++++++++ .../test_service_definition_inheritance.py | 3 ++- 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 src/nexusrpc/_util.py diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 300bee5..07c2402 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -20,6 +20,7 @@ overload, ) +from nexusrpc._util import get_annotations from nexusrpc.types import ( InputT, OutputT, @@ -128,9 +129,7 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: def _operations_from_annotations(cls) -> Iterator[Operation]: for parent_cls in reversed(cls.mro()): print(f"🟠 parent_cls: {parent_cls.__name__}") - # TODO(preview): backport inspect.get_annotations - # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older - annotations: dict[str, Any] = getattr(parent_cls, "__annotations__", {}) + annotations: dict[str, Any] = get_annotations(parent_cls) for annot_name, op in annotations.items(): print(f"🟡 annot_name: {annot_name}") if typing.get_origin(op) == Operation: diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py new file mode 100644 index 0000000..aae01f1 --- /dev/null +++ b/src/nexusrpc/_util.py @@ -0,0 +1,15 @@ +from typing import Any + +# TODO(preview): backport inspect.get_annotations +# See +# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older +# https://github.com/shawwn/get-annotations/blob/main/get_annotations/__init__.py +try: + from inspect import get_annotations as _get_annotations + + def get_annotations(obj: Any) -> dict[str, Any]: + return _get_annotations(obj) +except ImportError: + + def get_annotations(obj: Any) -> dict[str, Any]: + return getattr(obj, "__annotations__", {}) diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 2326668..5f8be75 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -4,6 +4,7 @@ import pytest from nexusrpc import Operation, ServiceDefinition, service +from nexusrpc._util import get_annotations class _TestCase: @@ -79,7 +80,7 @@ class A1: def test_user_service_definition_inheritance(test_case: Type[_TestCase]): print(f"\n\n{test_case.UserService.__name__}:") print("\n__annotations__") - pprint(test_case.UserService.__annotations__) + pprint(get_annotations(test_case.UserService)) print("\n__dict__") pprint(test_case.UserService.__dict__) From 45c076a8b81b739727189c7e3e3af18f2b0b4f28 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 14:41:46 -0400 Subject: [PATCH 031/178] Refactor --- src/nexusrpc/_service_definition.py | 24 ++++++++----------- .../test_service_decorator_validation.py | 4 +--- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 07c2402..f5c5400 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -13,7 +13,6 @@ Any, Callable, Generic, - Iterator, Optional, Type, Union, @@ -104,18 +103,9 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: # This will require forming a union of operations disovered via __annotations__ # and __dict__ - operations: dict[str, Operation] = {} - - for op in _operations_from_annotations(cls): - if op.name in operations: - raise ValueError( - f"Operation '{op.name}' in service '{service_name}' is defined multiple times" - ) - operations[op.name] = op - cls.__nexus_service__ = ServiceDefinition( # type: ignore name=service_name, - operations=operations, + operations=_operations_from_class(cls), ) return cls @@ -126,8 +116,9 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: return decorator(cls) -def _operations_from_annotations(cls) -> Iterator[Operation]: - for parent_cls in reversed(cls.mro()): +def _operations_from_class(cls) -> dict[str, Operation]: + operations: dict[str, Operation] = {} + for parent_cls in cls.mro(): print(f"🟠 parent_cls: {parent_cls.__name__}") annotations: dict[str, Any] = get_annotations(parent_cls) for annot_name, op in annotations.items(): @@ -160,4 +151,9 @@ def _operations_from_annotations(cls) -> Iterator[Operation]: op.input_type = input_type op.output_type = output_type - yield op + if op.name in operations: + raise ValueError( + f"Operation '{op.name}' in class '{parent_cls}' is defined multiple times" + ) + operations[op.name] = op + return operations diff --git a/tests/service_definition/test_service_decorator_validation.py b/tests/service_definition/test_service_decorator_validation.py index 5005c40..7ca259b 100644 --- a/tests/service_definition/test_service_decorator_validation.py +++ b/tests/service_definition/test_service_decorator_validation.py @@ -22,9 +22,7 @@ class Contract: a: nexusrpc.Operation[None, Output] = nexusrpc.Operation(name="a") b: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="a") - expected_error = ValueError( - "Operation 'a' in service 'Contract' is defined multiple times" - ) + expected_error = ValueError(r"Operation 'a' in class .* is defined multiple times") @pytest.mark.parametrize( From 810d0cb0f5acda73ff0ea28b81ed384297fe9fc9 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 16:25:23 -0400 Subject: [PATCH 032/178] Refactor One mysteriously fixed? tests/service_definition/test_service_definition_inheritance.py::test_user_service_definition_inheritance[TypeAnnotationsWithValuesAllFromParentClass] --- src/nexusrpc/_service_definition.py | 80 ++++++++++++++++------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index f5c5400..3efd02f 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -105,7 +105,7 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: cls.__nexus_service__ = ServiceDefinition( # type: ignore name=service_name, - operations=_operations_from_class(cls), + operations=_operations_from_class_mro(cls), ) return cls @@ -116,44 +116,50 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: return decorator(cls) -def _operations_from_class(cls) -> dict[str, Operation]: +def _operations_from_class_mro(cls) -> dict[str, Operation]: operations: dict[str, Operation] = {} for parent_cls in cls.mro(): - print(f"🟠 parent_cls: {parent_cls.__name__}") - annotations: dict[str, Any] = get_annotations(parent_cls) - for annot_name, op in annotations.items(): - print(f"🟡 annot_name: {annot_name}") - if typing.get_origin(op) == Operation: - args = typing.get_args(op) - if len(args) != 2: + operations.update(_operations_from_class(parent_cls)) + return operations + + +def _operations_from_class(parent_cls) -> dict[str, Operation]: + operations: dict[str, Operation] = {} + print(f"🟠 parent_cls: {parent_cls.__name__}") + annotations: dict[str, Any] = get_annotations(parent_cls) + for annot_name, op in annotations.items(): + print(f"🟡 annot_name: {annot_name}") + if typing.get_origin(op) == Operation: + args = typing.get_args(op) + if len(args) != 2: + raise TypeError( + f"Each operation in the service definition should look like " + f"nexusrpc.Operation[MyInputType, MyOutputType]. " + f"However, '{annot_name}' in '{parent_cls}' has {len(args)} type parameters." + ) + input_type, output_type = args + op = getattr(parent_cls, annot_name, None) + if not op: + op = Operation( + name=annot_name, + method_name=annot_name, + input_type=input_type, + output_type=output_type, + ) + setattr(parent_cls, annot_name, op) + else: + if not isinstance(op, Operation): raise TypeError( - f"Each operation in the service definition should look like " - f"nexusrpc.Operation[MyInputType, MyOutputType]. " - f"However, '{annot_name}' in '{cls}' has {len(args)} type parameters." - ) - input_type, output_type = args - op = getattr(cls, annot_name, None) - if not op: - op = Operation( - name=annot_name, - method_name=annot_name, - input_type=input_type, - output_type=output_type, - ) - setattr(cls, annot_name, op) - else: - if not isinstance(op, Operation): - raise TypeError( - f"Operation {annot_name} must be an instance of nexusrpc.Operation, " - f"but it is a {type(op)}" - ) - op.method_name = annot_name - op.input_type = input_type - op.output_type = output_type - - if op.name in operations: - raise ValueError( - f"Operation '{op.name}' in class '{parent_cls}' is defined multiple times" + f"Operation {annot_name} must be an instance of nexusrpc.Operation, " + f"but it is a {type(op)}" ) - operations[op.name] = op + op.method_name = annot_name + op.input_type = input_type + op.output_type = output_type + + if op.name in operations: + raise ValueError( + f"Operation '{op.name}' in class '{parent_cls}' is defined multiple times" + ) + operations[op.name] = op return operations From 4371d45962ae54c7e55762c3f3f3109ccd8311ae Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 16:33:15 -0400 Subject: [PATCH 033/178] Refactor: ServiceDefinition.from_user_clas --- src/nexusrpc/_service_definition.py | 95 +++++++++++++++-------------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 3efd02f..ed1da84 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -19,6 +19,8 @@ overload, ) +from typing_extensions import Self + from nexusrpc._util import get_annotations from nexusrpc.types import ( InputT, @@ -27,12 +29,53 @@ ) -# TODO(prerelease): support inheritance in service definitions @dataclass class ServiceDefinition: name: str operations: dict[str, Operation[Any, Any]] + @classmethod + def from_user_class(cls, user_class: Type[ServiceDefinitionT], name: str) -> Self: + operations: dict[str, Operation] = {} + print(f"🟠 user_class: {user_class.__name__}") + annotations: dict[str, Any] = get_annotations(user_class) + for annot_name, op in annotations.items(): + print(f"🟡 annot_name: {annot_name}") + if typing.get_origin(op) == Operation: + args = typing.get_args(op) + if len(args) != 2: + raise TypeError( + f"Each operation in the service definition should look like " + f"nexusrpc.Operation[MyInputType, MyOutputType]. " + f"However, '{annot_name}' in '{user_class}' has {len(args)} type parameters." + ) + input_type, output_type = args + op = getattr(user_class, annot_name, None) + if not op: + op = Operation( + name=annot_name, + method_name=annot_name, + input_type=input_type, + output_type=output_type, + ) + setattr(user_class, annot_name, op) + else: + if not isinstance(op, Operation): + raise TypeError( + f"Operation {annot_name} must be an instance of nexusrpc.Operation, " + f"but it is a {type(op)}" + ) + op.method_name = annot_name + op.input_type = input_type + op.output_type = output_type + + if op.name in operations: + raise ValueError( + f"Operation '{op.name}' in class '{user_class}' is defined multiple times" + ) + operations[op.name] = op + return cls(name=name, operations=operations) + @dataclass class Operation(Generic[InputT, OutputT]): @@ -116,50 +159,12 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: return decorator(cls) -def _operations_from_class_mro(cls) -> dict[str, Operation]: +def _operations_from_class_mro(cls: Type[ServiceDefinitionT]) -> dict[str, Operation]: operations: dict[str, Operation] = {} for parent_cls in cls.mro(): - operations.update(_operations_from_class(parent_cls)) - return operations - - -def _operations_from_class(parent_cls) -> dict[str, Operation]: - operations: dict[str, Operation] = {} - print(f"🟠 parent_cls: {parent_cls.__name__}") - annotations: dict[str, Any] = get_annotations(parent_cls) - for annot_name, op in annotations.items(): - print(f"🟡 annot_name: {annot_name}") - if typing.get_origin(op) == Operation: - args = typing.get_args(op) - if len(args) != 2: - raise TypeError( - f"Each operation in the service definition should look like " - f"nexusrpc.Operation[MyInputType, MyOutputType]. " - f"However, '{annot_name}' in '{parent_cls}' has {len(args)} type parameters." - ) - input_type, output_type = args - op = getattr(parent_cls, annot_name, None) - if not op: - op = Operation( - name=annot_name, - method_name=annot_name, - input_type=input_type, - output_type=output_type, - ) - setattr(parent_cls, annot_name, op) - else: - if not isinstance(op, Operation): - raise TypeError( - f"Operation {annot_name} must be an instance of nexusrpc.Operation, " - f"but it is a {type(op)}" - ) - op.method_name = annot_name - op.input_type = input_type - op.output_type = output_type - - if op.name in operations: - raise ValueError( - f"Operation '{op.name}' in class '{parent_cls}' is defined multiple times" - ) - operations[op.name] = op + operations.update( + ServiceDefinition.from_user_class( + parent_cls, parent_cls.__name__ + ).operations + ) return operations From 3548e0489f0cc26b2d5250c5768b09484fbb180e Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 16:40:38 -0400 Subject: [PATCH 034/178] Use ServiceDefinition if already computed Breaks tests/handler/test_service_handler_decorator_requirements.py::test_service_definition_inheritance_behavior[ServiceDefinitionInheritance] --- src/nexusrpc/_service_definition.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index ed1da84..9c1b7e5 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -162,9 +162,8 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: def _operations_from_class_mro(cls: Type[ServiceDefinitionT]) -> dict[str, Operation]: operations: dict[str, Operation] = {} for parent_cls in cls.mro(): - operations.update( - ServiceDefinition.from_user_class( - parent_cls, parent_cls.__name__ - ).operations - ) + defn = getattr( + parent_cls, "__nexus_service__", None + ) or ServiceDefinition.from_user_class(parent_cls, parent_cls.__name__) + operations.update(defn.operations) return operations From d604a66390d83b7e6847ab147823d73efa218b5d Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 17:06:17 -0400 Subject: [PATCH 035/178] Use first operation encountered in mro --- src/nexusrpc/_service_definition.py | 8 +++++--- .../test_service_definition_inheritance.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 9c1b7e5..72cca09 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -35,7 +35,7 @@ class ServiceDefinition: operations: dict[str, Operation[Any, Any]] @classmethod - def from_user_class(cls, user_class: Type[ServiceDefinitionT], name: str) -> Self: + def from_class(cls, user_class: Type[ServiceDefinitionT], name: str) -> Self: operations: dict[str, Operation] = {} print(f"🟠 user_class: {user_class.__name__}") annotations: dict[str, Any] = get_annotations(user_class) @@ -58,6 +58,7 @@ def from_user_class(cls, user_class: Type[ServiceDefinitionT], name: str) -> Sel input_type=input_type, output_type=output_type, ) + # TODO(prerelease): we must not mutate parent classes like this unless they are decorated setattr(user_class, annot_name, op) else: if not isinstance(op, Operation): @@ -164,6 +165,7 @@ def _operations_from_class_mro(cls: Type[ServiceDefinitionT]) -> dict[str, Opera for parent_cls in cls.mro(): defn = getattr( parent_cls, "__nexus_service__", None - ) or ServiceDefinition.from_user_class(parent_cls, parent_cls.__name__) - operations.update(defn.operations) + ) or ServiceDefinition.from_class(parent_cls, parent_cls.__name__) + for name, op in defn.operations.items(): + operations.setdefault(name, op) return operations diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 5f8be75..bc67eef 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -67,6 +67,7 @@ class A1: expected_operation_names = {"a", "b"} +# TODO: test mro is honored: that synonymous operation definition in child class wins @pytest.mark.parametrize( "test_case", [ From 8fd0f069a4de8da4a92cfcfd6475995961358f57 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 15 Jun 2025 22:36:53 -0400 Subject: [PATCH 036/178] Refactor: recursion --- src/nexusrpc/_service_definition.py | 172 ++++++++++++++++------------ src/nexusrpc/handler/_decorators.py | 2 + 2 files changed, 101 insertions(+), 73 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 72cca09..05b6826 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -13,14 +13,13 @@ Any, Callable, Generic, + Mapping, Optional, Type, Union, overload, ) -from typing_extensions import Self - from nexusrpc._util import get_annotations from nexusrpc.types import ( InputT, @@ -29,55 +28,6 @@ ) -@dataclass -class ServiceDefinition: - name: str - operations: dict[str, Operation[Any, Any]] - - @classmethod - def from_class(cls, user_class: Type[ServiceDefinitionT], name: str) -> Self: - operations: dict[str, Operation] = {} - print(f"🟠 user_class: {user_class.__name__}") - annotations: dict[str, Any] = get_annotations(user_class) - for annot_name, op in annotations.items(): - print(f"🟡 annot_name: {annot_name}") - if typing.get_origin(op) == Operation: - args = typing.get_args(op) - if len(args) != 2: - raise TypeError( - f"Each operation in the service definition should look like " - f"nexusrpc.Operation[MyInputType, MyOutputType]. " - f"However, '{annot_name}' in '{user_class}' has {len(args)} type parameters." - ) - input_type, output_type = args - op = getattr(user_class, annot_name, None) - if not op: - op = Operation( - name=annot_name, - method_name=annot_name, - input_type=input_type, - output_type=output_type, - ) - # TODO(prerelease): we must not mutate parent classes like this unless they are decorated - setattr(user_class, annot_name, op) - else: - if not isinstance(op, Operation): - raise TypeError( - f"Operation {annot_name} must be an instance of nexusrpc.Operation, " - f"but it is a {type(op)}" - ) - op.method_name = annot_name - op.input_type = input_type - op.output_type = output_type - - if op.name in operations: - raise ValueError( - f"Operation '{op.name}' in class '{user_class}' is defined multiple times" - ) - operations[op.name] = op - return cls(name=name, operations=operations) - - @dataclass class Operation(Generic[InputT, OutputT]): """Defines a Nexus operation in a Nexus service definition. @@ -135,22 +85,25 @@ class MyNexusService: my_operation: nexusrpc.Operation[MyInput, MyOutput] """ + # 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[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: if name is not None and not name: raise ValueError("Service name must not be empty.") - service_name = name or cls.__name__ - # 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__ - - cls.__nexus_service__ = ServiceDefinition( # type: ignore - name=service_name, - operations=_operations_from_class_mro(cls), - ) + defn = ServiceDefinition.from_user_class(cls, name or cls.__name__) + setattr(cls, "__nexus_service__", defn) + + # A decorated user service class must have a class attribute for each operation, + # the value of which is the operation instance; it is not sufficient for the + # operation to be represented by a type annotation alone. + for op_name, op in defn.operations.items(): + setattr(cls, op_name, op) return cls @@ -160,12 +113,85 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: return decorator(cls) -def _operations_from_class_mro(cls: Type[ServiceDefinitionT]) -> dict[str, Operation]: - operations: dict[str, Operation] = {} - for parent_cls in cls.mro(): - defn = getattr( - parent_cls, "__nexus_service__", None - ) or ServiceDefinition.from_class(parent_cls, parent_cls.__name__) - for name, op in defn.operations.items(): - operations.setdefault(name, op) - return operations +@dataclass +class ServiceDefinition: + name: str + operations: Mapping[str, Operation[Any, Any]] + + @classmethod + def from_user_class( + cls, user_class: Type[ServiceDefinitionT], name: str + ) -> ServiceDefinition: + """Create a ServiceDefinition from a user service definition class. + + All parent classes contribute operations to the ServiceDefinition, whether or not + they are decorated with @nexusrpc.service. + """ + # Recursively walk mro collecting operations not previously seen, stopping at an + # already-decorated service definition. + + # If this class is decorated then return the already-computed ServiceDefinition. + # (getattr would be affected by decorated parents) + if defn := user_class.__dict__.get("__nexus_service__"): + if isinstance(defn, ServiceDefinition): + return defn + + if user_class is object: + return ServiceDefinition(name=user_class.__name__, operations={}) + + parent = user_class.mro()[1] + parent_defn = ServiceDefinition.from_user_class(parent, parent.__name__) + defn = ServiceDefinition( + name=name, + operations=( + dict(parent_defn.operations) | cls._collect_operations(user_class) + ), + ) + + @staticmethod + def _collect_operations( + user_class: Type[ServiceDefinitionT], + ) -> dict[str, Operation[Any, Any]]: + """Collect operations from a user service definition class. + + Does not visit parent classes. + """ + operations: dict[str, Operation[Any, Any]] = {} + print(f"🟠 user_class: {user_class.__name__}") + annotations: dict[str, Any] = get_annotations(user_class) + for annot_name, op in annotations.items(): + print(f"🟡 annot_name: {annot_name}") + if typing.get_origin(op) != Operation: + continue + args = typing.get_args(op) + if len(args) != 2: + raise TypeError( + f"Each operation in the service definition should look like " + f"nexusrpc.Operation[MyInputType, MyOutputType]. " + f"However, '{annot_name}' in '{user_class}' has {len(args)} type parameters." + ) + input_type, output_type = args + op = getattr(user_class, annot_name, None) + if not op: + op = Operation( + name=annot_name, + method_name=annot_name, + input_type=input_type, + output_type=output_type, + ) + else: + if not isinstance(op, Operation): + raise TypeError( + f"Operation {annot_name} must be an instance of nexusrpc.Operation, " + f"but it is a {type(op)}" + ) + op.method_name = annot_name + op.input_type = input_type + op.output_type = output_type + + if op.name in operations: + raise ValueError( + f"Operation '{op.name}' in class '{user_class}' is defined multiple times" + ) + operations[op.name] = op + return operations diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index f13daa0..475a61c 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -102,6 +102,8 @@ class name will be used. ) _service = None if service: + # TODO(prerelease): This allows a non-decorated class to act as a service + # definition if it inherits from a decorated class. Is this what we want? _service = getattr(service, "__nexus_service__", None) if not _service: raise ValueError( From d86d67eb37e66a94fe1bd2366a2540803620bd9c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 16 Jun 2025 09:11:39 -0400 Subject: [PATCH 037/178] Test: use different input and output types --- .../test_service_definition_inheritance.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index bc67eef..83ca4da 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -6,6 +6,8 @@ from nexusrpc import Operation, ServiceDefinition, service from nexusrpc._util import get_annotations +# See https://docs.python.org/3/howto/annotations.html + class _TestCase: UserService: Type[Any] @@ -14,33 +16,32 @@ class _TestCase: class TypeAnnotationsOnly: class A1: - a: Operation[int, int] + a: Operation[int, str] class A2(A1): - b: Operation[int, int] + b: Operation[int, str] UserService = A2 expected_operation_names = {"a", "b"} -# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older - - class TypeAnnotationsWithValues: class A1: - a: Operation[int, int] = Operation[int, int](name="a-name") + a: Operation[int, str] = Operation[int, str](name="a-name") class A2(A1): - b: Operation[int, int] = Operation[int, int](name="b-name") + b: Operation[int, str] = Operation[int, str](name="b-name") UserService = A2 expected_operation_names = {"a-name", "b-name"} class TypeAnnotationsWithValuesAllFromParentClass: + # 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 class A1: - a: Operation[int, int] = Operation[int, int](name="a-name") - b: Operation[int, int] = Operation[int, int](name="b-name") + a: Operation[int, str] = Operation[int, str](name="a-name") + b: Operation[int, str] = Operation[int, str](name="b-name") class A2(A1): pass @@ -51,7 +52,7 @@ class A2(A1): class TypeValuesOnly: class A1: - a = Operation[int, int] + a = Operation[int, str] UserService = A1 expected_operation_names = {"a"} @@ -59,9 +60,9 @@ class A1: class ChildClassSynthesizedWithTypeValues: class A1: - a: Operation[int, int] + a: Operation[int, str] - A2 = type("A2", (A1,), {name: Operation[int, int] for name in ["b"]}) + A2 = type("A2", (A1,), {name: Operation[int, str] for name in ["b"]}) UserService = A2 expected_operation_names = {"a", "b"} @@ -88,3 +89,6 @@ def test_user_service_definition_inheritance(test_case: Type[_TestCase]): service_defn = getattr(service(test_case.UserService), "__nexus_service__", None) 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 == int + assert op.output_type == str From 38e5a91df0dd2ae2d55675cb1ba35a97923036a0 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 16 Jun 2025 10:11:33 -0400 Subject: [PATCH 038/178] Validate service definition on creation --- src/nexusrpc/_service_definition.py | 27 +++++++++++++++++++ .../test_service_definition_inheritance.py | 27 ++++++++++++++----- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 05b6826..00c5c09 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -49,6 +49,20 @@ class MyNexusService: input_type: Optional[Type[InputT]] = dataclasses.field(default=None) output_type: Optional[Type[OutputT]] = dataclasses.field(default=None) + 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[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: ... @@ -147,6 +161,19 @@ def from_user_class( dict(parent_defn.operations) | cls._collect_operations(user_class) ), ) + if errors := defn._validation_errors(): + raise ValueError( + f"Service definition {name} has validation errors: {errors}" + ) + return defn + + def _validation_errors(self) -> list[str]: + errors = [] + if not self.name: + errors.append("Service has no name") + for op in self.operations.values(): + errors.extend(op._validation_errors()) + return errors @staticmethod def _collect_operations( diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 83ca4da..8922907 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -1,5 +1,5 @@ from pprint import pprint -from typing import Any, Type +from typing import Any, Optional, Type import pytest @@ -12,9 +12,10 @@ class _TestCase: UserService: Type[Any] expected_operation_names: set[str] + expected_error: Optional[str] = None -class TypeAnnotationsOnly: +class TypeAnnotationsOnly(_TestCase): class A1: a: Operation[int, str] @@ -25,7 +26,7 @@ class A2(A1): expected_operation_names = {"a", "b"} -class TypeAnnotationsWithValues: +class TypeAnnotationsWithValues(_TestCase): class A1: a: Operation[int, str] = Operation[int, str](name="a-name") @@ -36,7 +37,7 @@ class A2(A1): expected_operation_names = {"a-name", "b-name"} -class TypeAnnotationsWithValuesAllFromParentClass: +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 class A1: @@ -50,7 +51,15 @@ class A2(A1): expected_operation_names = {"a-name", "b-name"} -class TypeValuesOnly: +class InstanceWithoutTypeAnnotationIsAnError(_TestCase): + class A1: + a = Operation[int, str](name="a-name") + + UserService = A1 + expected_error = "Each operation in the service definition should look like " + + +class TypeValuesOnly(_TestCase): class A1: a = Operation[int, str] @@ -58,7 +67,7 @@ class A1: expected_operation_names = {"a"} -class ChildClassSynthesizedWithTypeValues: +class ChildClassSynthesizedWithTypeValues(_TestCase): class A1: a: Operation[int, str] @@ -75,6 +84,7 @@ class A1: TypeAnnotationsOnly, TypeAnnotationsWithValues, TypeAnnotationsWithValuesAllFromParentClass, + InstanceWithoutTypeAnnotationIsAnError, TypeValuesOnly, ChildClassSynthesizedWithTypeValues, ], @@ -86,6 +96,11 @@ def test_user_service_definition_inheritance(test_case: Type[_TestCase]): print("\n__dict__") pprint(test_case.UserService.__dict__) + if test_case.expected_error: + with pytest.raises(Exception, match=test_case.expected_error): + service(test_case.UserService) + return + service_defn = getattr(service(test_case.UserService), "__nexus_service__", None) assert isinstance(service_defn, ServiceDefinition) assert set(service_defn.operations) == test_case.expected_operation_names From e48eb120b395786853a55ebd7798727e6681c424 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 16 Jun 2025 11:56:19 -0400 Subject: [PATCH 039/178] Support sync and async users --- src/nexusrpc/_util.py | 2 +- src/nexusrpc/asyncio/__init__.py | 2 + src/nexusrpc/asyncio/_serializer.py | 27 +++++ src/nexusrpc/asyncio/handler/__init__.py | 10 ++ src/nexusrpc/asyncio/handler/_core.py | 106 ++++++++++++++++++ src/nexusrpc/handler/__init__.py | 5 +- src/nexusrpc/handler/_core.py | 96 ++++++---------- src/nexusrpc/handler/_serializer.py | 35 +++--- src/nexusrpc/syncio/__init__.py | 1 + src/nexusrpc/syncio/_serializer.py | 34 ++++++ src/nexusrpc/syncio/handler/__init__.py | 1 + src/nexusrpc/syncio/handler/_core.py | 97 ++++++++++++++++ tests/handler/test_handler_sync.py | 56 +++++++++ ...er_validates_service_handler_collection.py | 2 +- 14 files changed, 389 insertions(+), 85 deletions(-) create mode 100644 src/nexusrpc/asyncio/__init__.py create mode 100644 src/nexusrpc/asyncio/_serializer.py create mode 100644 src/nexusrpc/asyncio/handler/__init__.py create mode 100644 src/nexusrpc/asyncio/handler/_core.py create mode 100644 src/nexusrpc/syncio/__init__.py create mode 100644 src/nexusrpc/syncio/_serializer.py create mode 100644 src/nexusrpc/syncio/handler/__init__.py create mode 100644 src/nexusrpc/syncio/handler/_core.py create mode 100644 tests/handler/test_handler_sync.py diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index aae01f1..7e4f40d 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -5,7 +5,7 @@ # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older # https://github.com/shawwn/get-annotations/blob/main/get_annotations/__init__.py try: - from inspect import get_annotations as _get_annotations + from inspect import get_annotations as _get_annotations # type: ignore def get_annotations(obj: Any) -> dict[str, Any]: return _get_annotations(obj) diff --git a/src/nexusrpc/asyncio/__init__.py b/src/nexusrpc/asyncio/__init__.py new file mode 100644 index 0000000..7d2ad61 --- /dev/null +++ b/src/nexusrpc/asyncio/__init__.py @@ -0,0 +1,2 @@ +from ._serializer import LazyValue as LazyValue +from .handler import Handler as Handler diff --git a/src/nexusrpc/asyncio/_serializer.py b/src/nexusrpc/asyncio/_serializer.py new file mode 100644 index 0000000..5fc75af --- /dev/null +++ b/src/nexusrpc/asyncio/_serializer.py @@ -0,0 +1,27 @@ +from typing import Any, AsyncIterable, Optional, Type + +import nexusrpc.handler +from nexusrpc.handler._serializer import Content + + +class LazyValue(nexusrpc.handler.LazyValue): + __doc__ = nexusrpc.handler.LazyValue.__doc__ + stream: Optional[AsyncIterable[bytes]] + + async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + """ + Consume the underlying reader stream, deserializing via the embedded serializer. + """ + # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? + if self.stream is None: + return await self.serializer.deserialize( + Content(headers=self.headers), as_type=as_type + ) + + 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/asyncio/handler/__init__.py b/src/nexusrpc/asyncio/handler/__init__.py new file mode 100644 index 0000000..8506bf0 --- /dev/null +++ b/src/nexusrpc/asyncio/handler/__init__.py @@ -0,0 +1,10 @@ +# TODO(preview): show what it looks like to manually build a service implementation at runtime +# where the operations may be based on some runtime information. + +# TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" +# TODO(preview): pass mypy + + +from __future__ import annotations + +from ._core import Handler as Handler diff --git a/src/nexusrpc/asyncio/handler/_core.py b/src/nexusrpc/asyncio/handler/_core.py new file mode 100644 index 0000000..15d6005 --- /dev/null +++ b/src/nexusrpc/asyncio/handler/_core.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import inspect +from typing import ( + Any, + Union, +) + +import nexusrpc.handler +from nexusrpc.handler import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + OperationInfo, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, +) +from nexusrpc.handler._util import is_async_callable + + +class Handler(nexusrpc.handler.BaseHandler): + """ + A Nexus handler with `async def` methods. + + A Nexus handler manages a collection of Nexus service handlers. + + Operation requests are delegated to a :py:class:`ServiceHandler` based on the service + name in the operation context. + """ + + async def start_operation( + self, + ctx: StartOperationContext, + input: nexusrpc.handler.LazyValue, + ) -> 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) + + if is_async_callable(op_handler.start): + # TODO(preview): apply middleware stack as composed awaitables + return await op_handler.start(ctx, deserialized_input) + else: + # TODO(preview): apply middleware stack as composed functions + if not self.sync_executor: + raise RuntimeError( + "Operation start handler method is not an `async def` but " + "no sync executor was provided to the Handler constructor. " + ) + result = await self.sync_executor.submit_to_event_loop( + op_handler.start, ctx, deserialized_input + ) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation start handler method {op_handler.start} returned an " + "awaitable but is not an `async def` coroutine function." + ) + return result + + 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: + if not self.sync_executor: + raise RuntimeError( + "Operation cancel handler method is not an `async def` function but " + "no executor was provided to the Handler constructor." + ) + result = await self.sync_executor.submit_to_event_loop( + op_handler.cancel, ctx, token + ) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation cancel handler method {op_handler.cancel} returned an " + "awaitable but is not an `async def` function." + ) + return result + + async def fetch_operation_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> OperationInfo: + raise NotImplementedError + + async def fetch_operation_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Any: + raise NotImplementedError diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 6c9227b..5135d6b 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -50,11 +50,14 @@ StartOperationResultSync as StartOperationResultSync, ) from ._core import ( - Handler as Handler, + BaseHandler as BaseHandler, ) from ._core import ( OperationHandler as OperationHandler, ) +from ._core import ( + SyncExecutor as SyncExecutor, +) from ._core import ( SyncOperationHandler as SyncOperationHandler, ) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index f8e5623..ceb3ef3 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -5,7 +5,7 @@ import typing import warnings from abc import ABC, abstractmethod -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass from typing import ( Any, @@ -39,8 +39,7 @@ from ._serializer import LazyValue -@dataclass -class Handler: +class BaseHandler(ABC): """ A Nexus handler, managing a collection of Nexus service handlers. @@ -48,8 +47,6 @@ class Handler: name in the operation context. """ - service_handlers: dict[str, ServiceHandler] - def __init__( self, user_service_handlers: Sequence[Any], @@ -93,89 +90,53 @@ def __init__( ) self.service_handlers[sh.service.name] = sh - async def start_operation( + @abstractmethod + def start_operation( self, ctx: StartOperationContext, input: LazyValue, ) -> 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] - input = await input.consume(as_type=op.input_type) - - if is_async_callable(op_handler.start): - # TODO(preview): apply middleware stack as composed awaitables - return await op_handler.start(ctx, input) - else: - # TODO(preview): apply middleware stack as composed functions - if not self.sync_executor: - raise RuntimeError( - "Operation start handler method is not an `async def` but " - "no sync executor was provided to the Handler constructor. " - ) - result = await self.sync_executor.run_sync(op_handler.start, ctx, input) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation start handler method {op_handler.start} returned an " - "awaitable but is not an `async def` coroutine function." - ) - return result + Awaitable[StartOperationResultSync[Any]], + Awaitable[StartOperationResultAsync], + ]: ... - async def fetch_operation_info( + @abstractmethod + def fetch_operation_info( self, ctx: FetchOperationInfoContext, token: str - ) -> OperationInfo: + ) -> Union[OperationInfo, Awaitable[OperationInfo]]: """Handle a Fetch Operation Info request. Args: ctx: The operation context. token: The operation token. """ - raise NotImplementedError + ... - async def fetch_operation_result( + @abstractmethod + def fetch_operation_result( self, ctx: FetchOperationResultContext, token: str - ) -> Any: + ) -> Union[Any, Awaitable[Any]]: """Handle a Fetch Operation Result request. Args: ctx: The operation context. token: The operation token. """ - raise NotImplementedError + ... - async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: + @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. """ - 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: - if not self.sync_executor: - raise RuntimeError( - "Operation cancel handler method is not an `async def` function but " - "no executor was provided to the Handler constructor." - ) - result = await self.sync_executor.run_sync(op_handler.cancel, ctx, token) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation cancel handler method {op_handler.cancel} returned an " - "awaitable but is not an `async def` function." - ) - return result + ... def _get_service_handler(self, service_name: str) -> ServiceHandler: """Return a service handler, given the service name.""" @@ -380,7 +341,13 @@ def collect_operation_handler_factories( """ factories = {} op_defn_method_names = ( - {op.method_name for op in service.operations.values()} if service else set() + { + op.method_name + for op in service.operations.values() + if op.method_name is not None + } + if service + else set() ) for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): op_defn = getattr(method, "__nexus_operation__", None) @@ -508,11 +475,16 @@ def service_from_operation_handler_methods( class SyncExecutor: """ - Run a synchronous function asynchronously. + Run a synchronous function in an executor. """ def __init__(self, executor: ThreadPoolExecutor): self._executor = executor - def run_sync(self, fn: Callable[..., Any], *args: Any) -> Awaitable[Any]: + 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) -> Future[Any]: + return self._executor.submit(fn, *args) diff --git a/src/nexusrpc/handler/_serializer.py b/src/nexusrpc/handler/_serializer.py index 486a98d..97bf064 100644 --- a/src/nexusrpc/handler/_serializer.py +++ b/src/nexusrpc/handler/_serializer.py @@ -1,13 +1,17 @@ from __future__ import annotations +from abc import ABC, abstractmethod from dataclasses import dataclass from typing import ( Any, AsyncIterable, + Awaitable, + Iterable, Mapping, Optional, Protocol, Type, + Union, ) @@ -37,15 +41,15 @@ class Serializer(Protocol): # TODO(preview): support non-async def - async def serialize(self, value: Any) -> Content: + def serialize(self, value: Any) -> Union[Content, Awaitable[Content]]: """Serialize encodes a value into a Content.""" ... # TODO(prerelease): does None work as the sentinel type here, meaning do not attempt # type conversion, despite the fact that Python treats None as a valid type? - async def deserialize( + def deserialize( self, content: Content, as_type: Optional[Type[Any]] = None - ) -> Any: + ) -> Union[Any, Awaitable[Any]]: """Deserialize decodes a Content into a value. Args: @@ -56,7 +60,7 @@ async def deserialize( ... -class LazyValue: +class LazyValue(ABC): """ 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. @@ -66,7 +70,7 @@ def __init__( self, serializer: Serializer, headers: Mapping[str, str], - stream: Optional[AsyncIterable[bytes]] = None, + stream: Optional[Union[AsyncIterable[bytes], Iterable[bytes]]] = None, ) -> None: """ Args: @@ -74,26 +78,17 @@ def __init__( 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: AsyncIterable that contains request or response data. None means empty data. + 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: + @abstractmethod + def consume( + self, as_type: Optional[Type[Any]] = None + ) -> Union[Any, Awaitable[Any]]: """ Consume the underlying reader stream, deserializing via the embedded serializer. """ - # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? - if self.stream is None: - return await self.serializer.deserialize( - Content(headers=self.headers), as_type=as_type - ) - - 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/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py new file mode 100644 index 0000000..fd0909b --- /dev/null +++ b/src/nexusrpc/syncio/__init__.py @@ -0,0 +1 @@ +from ._serializer import LazyValue as LazyValue diff --git a/src/nexusrpc/syncio/_serializer.py b/src/nexusrpc/syncio/_serializer.py new file mode 100644 index 0000000..937811f --- /dev/null +++ b/src/nexusrpc/syncio/_serializer.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import ( + Any, + Iterable, + Optional, + Type, +) + +import nexusrpc.handler +from nexusrpc.handler import Content + + +class LazyValue(nexusrpc.handler.LazyValue): + __doc__ = nexusrpc.handler.LazyValue.__doc__ + stream: Optional[Iterable[bytes]] + + def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + """ + Consume the underlying reader stream, deserializing via the embedded serializer. + """ + # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? + if self.stream is None: + return self.serializer.deserialize( + Content(headers=self.headers), as_type=as_type + ) + + 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..777dab9 --- /dev/null +++ b/src/nexusrpc/syncio/handler/__init__.py @@ -0,0 +1 @@ +from ._core import Handler as Handler diff --git a/src/nexusrpc/syncio/handler/_core.py b/src/nexusrpc/syncio/handler/_core.py new file mode 100644 index 0000000..9e226e0 --- /dev/null +++ b/src/nexusrpc/syncio/handler/_core.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import ( + Any, + Union, +) + +import nexusrpc.handler +from nexusrpc.handler._common import ( + FetchOperationInfoContext, + FetchOperationResultContext, + OperationInfo, +) +from nexusrpc.handler._core import ( + CancelOperationContext, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, +) +from nexusrpc.handler._util import is_async_callable + + +class Handler(nexusrpc.handler.BaseHandler): + """ + A Nexus handler with non-async `def` methods. + + A Nexus handler manages 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 start_operation( + self, + ctx: StartOperationContext, + input: nexusrpc.handler.LazyValue, + ) -> 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) + + if is_async_callable(op_handler.start): + raise RuntimeError( + "Operation start handler method is an `async def` and " + "cannot be called from a sync handler. " + ) + # TODO(preview): apply middleware stack as composed functions + if not self.sync_executor: + raise RuntimeError( + "Operation start handler method is not an `async def` but " + "no sync executor was provided to the Handler constructor. " + ) + return self.sync_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) + if is_async_callable(op_handler.cancel): + raise RuntimeError( + "Operation cancel handler method is an `async def` and " + "cannot be called from a sync handler. " + ) + else: + if not self.sync_executor: + raise RuntimeError( + "Operation cancel handler method is not an `async def` function but " + "no executor was provided to the Handler constructor." + ) + return self.sync_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 diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py new file mode 100644 index 0000000..5bf4181 --- /dev/null +++ b/tests/handler/test_handler_sync.py @@ -0,0 +1,56 @@ +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Any, Optional, Type + +import pytest + +from nexusrpc.handler import ( + StartOperationContext, + SyncExecutor, + service_handler, +) +from nexusrpc.handler._common import StartOperationResultSync +from nexusrpc.handler._decorators import sync_operation_handler +from nexusrpc.handler._serializer import Content +from nexusrpc.syncio import LazyValue +from nexusrpc.syncio.handler import Handler + + +class _TestCase: + user_service_handler: Any + + +class SyncHandlerHappyPath: + @service_handler + class MyService: + @sync_operation_handler + 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], + sync_executor=SyncExecutor(executor=ThreadPoolExecutor(max_workers=1)), + ) + ctx = StartOperationContext( + service="MyService", + operation="incr", + ) + 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 index 7863ca2..e94479f 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -6,7 +6,7 @@ import pytest import nexusrpc.handler -from nexusrpc.handler import Handler +from nexusrpc.asyncio.handler import Handler def test_service_must_use_decorator(): From d443b29ed3613f7ab1d96adbf739480eac9b0e17 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 16 Jun 2025 23:29:30 -0400 Subject: [PATCH 040/178] Evolve collection of operations --- src/nexusrpc/_service_definition.py | 72 +++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 00c5c09..872a50f 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -175,46 +175,88 @@ def _validation_errors(self) -> list[str]: errors.extend(op._validation_errors()) return errors - @staticmethod + @classmethod def _collect_operations( + cls, user_class: Type[ServiceDefinitionT], ) -> dict[str, Operation[Any, Any]]: """Collect operations from a user service definition class. Does not visit parent classes. """ + from_annotations = cls._collect_operations_from_annotations(user_class) + from_class_attributes = cls._collect_operations_from_class_attributes( + user_class + ) + + return from_annotations | from_class_attributes + + @classmethod + def _collect_operations_from_class_attributes( + cls, + user_class: Type[ServiceDefinitionT], + ) -> dict[str, Operation[Any, Any]]: + operations: dict[str, Operation[Any, Any]] = {} + for name, op in user_class.__dict__.items(): + if isinstance(op, Operation): + operations[name] = op + return operations + + @classmethod + def _collect_operations_from_annotations( + cls, + user_class: Type[ServiceDefinitionT], + ) -> dict[str, Operation[Any, Any]]: operations: dict[str, Operation[Any, Any]] = {} - print(f"🟠 user_class: {user_class.__name__}") - annotations: dict[str, Any] = get_annotations(user_class) - for annot_name, op in annotations.items(): - print(f"🟡 annot_name: {annot_name}") - if typing.get_origin(op) != Operation: + for name, op_type in get_annotations(user_class).items(): + if typing.get_origin(op_type) != Operation: continue - args = typing.get_args(op) + + args = typing.get_args(op_type) if len(args) != 2: raise TypeError( f"Each operation in the service definition should look like " f"nexusrpc.Operation[MyInputType, MyOutputType]. " - f"However, '{annot_name}' in '{user_class}' has {len(args)} type parameters." + f"However, '{name}' in '{user_class}' has {len(args)} type parameters." ) input_type, output_type = args - op = getattr(user_class, annot_name, None) + + op = getattr(user_class, name, None) if not op: + # It looked like + # my_op: Operation[I, O] op = Operation( - name=annot_name, - method_name=annot_name, + name=name, + method_name=name, input_type=input_type, output_type=output_type, ) else: + # It looked like + # my_op: Operation[I, O] = Operation(...) if not isinstance(op, Operation): raise TypeError( - f"Operation {annot_name} must be an instance of nexusrpc.Operation, " + f"Operation {name} must be an instance of nexusrpc.Operation, " f"but it is a {type(op)}" ) - op.method_name = annot_name - op.input_type = input_type - op.output_type = output_type + if not op.method_name: + op.method_name = name + else: + raise ValueError( + f"Operation {name} method_name ({op.method_name}) must match attribute name {name}" + ) + if not op.input_type: + op.input_type = input_type + else: + raise ValueError( + f"Operation {name} input_type ({op.input_type}) must match type parameter {input_type}" + ) + if not op.output_type: + op.output_type = output_type + else: + raise ValueError( + f"Operation {name} output_type ({op.output_type}) must match type parameter {output_type}" + ) if op.name in operations: raise ValueError( From 8c0695517ea4631978605a366e3872c36e438e8a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 16 Jun 2025 23:51:35 -0400 Subject: [PATCH 041/178] Collect operations from attributes as well as annotations --- src/nexusrpc/_service_definition.py | 84 +++++++++---------- .../test_service_definition_inheritance.py | 4 +- 2 files changed, 43 insertions(+), 45 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 872a50f..55f9560 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -49,6 +49,10 @@ class MyNexusService: 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: @@ -113,9 +117,10 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: defn = ServiceDefinition.from_user_class(cls, name or cls.__name__) setattr(cls, "__nexus_service__", defn) - # A decorated user service class must have a class attribute for each operation, - # the value of which is the operation instance; it is not sufficient for the - # operation to be represented by a type annotation alone. + # 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. for op_name, op in defn.operations.items(): setattr(cls, op_name, op) @@ -132,9 +137,9 @@ class ServiceDefinition: name: str operations: Mapping[str, Operation[Any, Any]] - @classmethod + @staticmethod def from_user_class( - cls, user_class: Type[ServiceDefinitionT], name: str + user_class: Type[ServiceDefinitionT], name: str ) -> ServiceDefinition: """Create a ServiceDefinition from a user service definition class. @@ -145,7 +150,7 @@ def from_user_class( # already-decorated service definition. # If this class is decorated then return the already-computed ServiceDefinition. - # (getattr would be affected by decorated parents) + # Do not use getattr since it would retrieve a value from a decorated parent class. if defn := user_class.__dict__.get("__nexus_service__"): if isinstance(defn, ServiceDefinition): return defn @@ -158,12 +163,13 @@ def from_user_class( defn = ServiceDefinition( name=name, operations=( - dict(parent_defn.operations) | cls._collect_operations(user_class) + dict(parent_defn.operations) + | ServiceDefinition._collect_operations(user_class) ), ) if errors := defn._validation_errors(): raise ValueError( - f"Service definition {name} has validation errors: {errors}" + f"Service definition {name} has validation errors: {', '.join(errors)}" ) return defn @@ -175,53 +181,43 @@ def _validation_errors(self) -> list[str]: errors.extend(op._validation_errors()) return errors - @classmethod + @staticmethod def _collect_operations( - cls, user_class: Type[ServiceDefinitionT], ) -> dict[str, Operation[Any, Any]]: """Collect operations from a user service definition class. Does not visit parent classes. """ - from_annotations = cls._collect_operations_from_annotations(user_class) - from_class_attributes = cls._collect_operations_from_class_attributes( - user_class - ) - - return from_annotations | from_class_attributes - @classmethod - def _collect_operations_from_class_attributes( - cls, - user_class: Type[ServiceDefinitionT], - ) -> dict[str, Operation[Any, Any]]: - operations: dict[str, Operation[Any, Any]] = {} - for name, op in user_class.__dict__.items(): - if isinstance(op, Operation): - operations[name] = op - return operations + # Combine attribute instance and type annotation for all keys. + attrs_and_annotations: dict[ + str, tuple[Optional[Operation], Optional[Type[Operation]]] + ] = { + v.name: (v, None) + for v in user_class.__dict__.values() + if isinstance(v, Operation) + } + for name, op_type in get_annotations(user_class).items(): + if typing.get_origin(op_type) == Operation: + op, _ = attrs_and_annotations.get(name, (None, None)) + attrs_and_annotations[name] = (op, op_type) - @classmethod - def _collect_operations_from_annotations( - cls, - user_class: Type[ServiceDefinitionT], - ) -> dict[str, Operation[Any, Any]]: + # Create an Operation instance for each key. operations: dict[str, Operation[Any, Any]] = {} - for name, op_type in get_annotations(user_class).items(): - if typing.get_origin(op_type) != Operation: - continue - - args = typing.get_args(op_type) - if len(args) != 2: - raise TypeError( - f"Each operation in the service definition should look like " - f"nexusrpc.Operation[MyInputType, MyOutputType]. " - f"However, '{name}' in '{user_class}' has {len(args)} type parameters." - ) - input_type, output_type = args + for name, (op, op_type) in attrs_and_annotations.items(): + if op_type: + 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[MyInputType, MyOutputType]. " + f"However, '{name}' in '{user_class}' has {len(args)} type parameters." + ) + input_type, output_type = args + else: + input_type = output_type = None - op = getattr(user_class, name, None) if not op: # It looked like # my_op: Operation[I, O] diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 8922907..f9904d5 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -56,7 +56,9 @@ class A1: a = Operation[int, str](name="a-name") UserService = A1 - expected_error = "Each operation in the service definition should look like " + expected_error = ( + "Operation 'a-name' has no input type, Operation 'a-name' has no output type" + ) class TypeValuesOnly(_TestCase): From e93c4c0f1debfadb501caf0f13fa7f71cbff4731 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 17 Jun 2025 08:36:07 -0400 Subject: [PATCH 042/178] Refactor --- src/nexusrpc/_service_definition.py | 98 +++++++++++++---------------- 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 55f9560..7a8531d 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -190,73 +190,63 @@ def _collect_operations( Does not visit parent classes. """ - # Combine attribute instance and type annotation for all keys. - attrs_and_annotations: dict[ - str, tuple[Optional[Operation], Optional[Type[Operation]]] - ] = { - v.name: (v, None) - for v in user_class.__dict__.values() - if isinstance(v, Operation) + # 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]] = { + v.name: v for v in user_class.__dict__.values() if isinstance(v, Operation) } - for name, op_type in get_annotations(user_class).items(): - if typing.get_origin(op_type) == Operation: - op, _ = attrs_and_annotations.get(name, (None, None)) - attrs_and_annotations[name] = (op, op_type) - - # Create an Operation instance for each key. - operations: dict[str, Operation[Any, Any]] = {} - for name, (op, op_type) in attrs_and_annotations.items(): - if op_type: + annotations = { + k: v + for k, v in get_annotations(user_class).items() + if typing.get_origin(v) == Operation + } + for name 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(name): 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[MyInputType, MyOutputType]. " - f"However, '{name}' in '{user_class}' has {len(args)} type parameters." + f"nexusrpc.Operation[InputType, OutputType], but '{name}' in " + f"'{user_class}' has {len(args)} type parameters." ) input_type, output_type = args - else: - input_type = output_type = None - - if not op: - # It looked like - # my_op: Operation[I, O] - op = Operation( - name=name, - method_name=name, - input_type=input_type, - output_type=output_type, - ) + if name not in operations: + # It looked like + # my_op: Operation[I, O] + operations[name] = Operation( + name=name, + method_name=name, + input_type=input_type, + output_type=output_type, + ) + else: + op = operations[name] + # 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 {name} 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 {name} output_type ({op.output_type}) must match type parameter {output_type}" + ) else: # It looked like - # my_op: Operation[I, O] = Operation(...) - if not isinstance(op, Operation): - raise TypeError( - f"Operation {name} must be an instance of nexusrpc.Operation, " - f"but it is a {type(op)}" - ) + # my_op = Operation(...) + op = operations[name] if not op.method_name: op.method_name = name - else: + elif op.method_name != name: raise ValueError( f"Operation {name} method_name ({op.method_name}) must match attribute name {name}" ) - if not op.input_type: - op.input_type = input_type - else: - raise ValueError( - f"Operation {name} input_type ({op.input_type}) must match type parameter {input_type}" - ) - if not op.output_type: - op.output_type = output_type - else: - raise ValueError( - f"Operation {name} output_type ({op.output_type}) must match type parameter {output_type}" - ) - if op.name in operations: - raise ValueError( - f"Operation '{op.name}' in class '{user_class}' is defined multiple times" - ) - operations[op.name] = op return operations From 1b6fd901a323ea9d7b3a7dcd4c6a7ed096a2c2f1 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 17 Jun 2025 10:52:57 -0400 Subject: [PATCH 043/178] Fixups --- src/nexusrpc/_service_definition.py | 14 +++++++++++--- .../test_service_definition_inheritance.py | 8 ++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 7a8531d..0572e7f 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -192,9 +192,17 @@ def _collect_operations( # 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]] = { - v.name: v for v in user_class.__dict__.values() if isinstance(v, Operation) - } + operations = {} + for v in user_class.__dict__.values(): + if isinstance(v, Operation): + operations[v.name] = 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).items() diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index f9904d5..68f05f1 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -61,12 +61,12 @@ class A1: ) -class TypeValuesOnly(_TestCase): +class InvalidUseOfTypeAsValue(_TestCase): class A1: a = Operation[int, str] UserService = A1 - expected_operation_names = {"a"} + expected_error = "Did you accidentally use '=' instead of ':'" class ChildClassSynthesizedWithTypeValues(_TestCase): @@ -76,7 +76,7 @@ class A1: A2 = type("A2", (A1,), {name: Operation[int, str] for name in ["b"]}) UserService = A2 - expected_operation_names = {"a", "b"} + expected_error = "Did you accidentally use '=' instead of ':'" # TODO: test mro is honored: that synonymous operation definition in child class wins @@ -87,7 +87,7 @@ class A1: TypeAnnotationsWithValues, TypeAnnotationsWithValuesAllFromParentClass, InstanceWithoutTypeAnnotationIsAnError, - TypeValuesOnly, + InvalidUseOfTypeAsValue, ChildClassSynthesizedWithTypeValues, ], ) From 7280381f24598b0da540cdd2d777d9ce50df561c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 17 Jun 2025 11:20:29 -0400 Subject: [PATCH 044/178] Use keys throughout; map to name overrides at end --- src/nexusrpc/_service_definition.py | 40 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 0572e7f..5a8f92f 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -45,6 +45,7 @@ class MyNexusService: """ 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) @@ -192,10 +193,10 @@ def _collect_operations( # Form the union of all class attribute names that are either an Operation # instance or have an Operation type annotation, or both. - operations = {} - for v in user_class.__dict__.values(): + operations: dict[str, Operation[Any, Any]] = {} + for k, v in user_class.__dict__.items(): if isinstance(v, Operation): - operations[v.name] = v + operations[k] = v elif typing.get_origin(v) is Operation: raise TypeError( "Operation definitions in the service definition should look like " @@ -208,53 +209,56 @@ def _collect_operations( for k, v in get_annotations(user_class).items() if typing.get_origin(v) == Operation } - for name in operations.keys() | annotations.keys(): + 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(name): + 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 '{name}' in " + f"nexusrpc.Operation[InputType, OutputType], but '{key}' in " f"'{user_class}' has {len(args)} type parameters." ) input_type, output_type = args - if name not in operations: + if key not in operations: # It looked like # my_op: Operation[I, O] - operations[name] = Operation( - name=name, - method_name=name, + op = operations[key] = Operation( + name=key, + method_name=key, input_type=input_type, output_type=output_type, ) else: - op = operations[name] + 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 {name} input_type ({op.input_type}) must match type parameter {input_type}" + 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 {name} output_type ({op.output_type}) must match type parameter {output_type}" + f"Operation {key} output_type ({op.output_type}) must match type parameter {output_type}" ) else: # It looked like # my_op = Operation(...) - op = operations[name] + op = operations[key] if not op.method_name: - op.method_name = name - elif op.method_name != name: + op.method_name = key + elif op.method_name != key: raise ValueError( - f"Operation {name} method_name ({op.method_name}) must match attribute name {name}" + f"Operation {key} method_name ({op.method_name}) must match attribute name {key}" ) - return operations + if op.method_name is None: + op.method_name = key + + return {op.name: op for op in operations.values()} From a0636761513ae4794fbe5226a96e76cf0214abf1 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 17 Jun 2025 12:22:04 -0400 Subject: [PATCH 045/178] Fix operation merge edge case --- src/nexusrpc/_service_definition.py | 49 +++++++++++++++++-- .../test_service_definition_inheritance.py | 11 +++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service_definition.py index 5a8f92f..b552ed8 100644 --- a/src/nexusrpc/_service_definition.py +++ b/src/nexusrpc/_service_definition.py @@ -161,11 +161,12 @@ def from_user_class( parent = user_class.mro()[1] parent_defn = ServiceDefinition.from_user_class(parent, parent.__name__) + + # Update the inherited operations with those collected at this level. defn = ServiceDefinition( name=name, - operations=( - dict(parent_defn.operations) - | ServiceDefinition._collect_operations(user_class) + operations=ServiceDefinition._merge_operations( + parent_defn.operations, user_class ), ) if errors := defn._validation_errors(): @@ -182,6 +183,39 @@ def _validation_errors(self) -> list[str]: errors.extend(op._validation_errors()) return errors + @staticmethod + def _merge_operations( + parent_operations: Mapping[str, Operation[Any, Any]], + user_class: Type[ServiceDefinitionT], + ) -> dict[str, Operation[Any, Any]]: + merged = dict(parent_operations) + parent_ops_by_method_name = {op.method_name: op for op in merged.values()} + for op_name, op in ServiceDefinition._collect_operations(user_class).items(): + # If the operation at this level derives from an annotation alone (no + # accompanying instance), then merge information from the inherited + # operation, as long as it doesn't conflict. We look up by method name; if + # the op at this level derives from an annotation alone then it has not + # overridden its name. + if parent_op := parent_ops_by_method_name.get(op_name): + if op_name not in user_class.__dict__: + # TODO(prerelease): what about if they are both type annotations? Then the later one should win. + if op.input_type != parent_op.input_type: + raise TypeError( + f"Operation '{op_name}' in class '{user_class}' has input_type " + f"({op.input_type}). This does not match the type of the same " + f"operation in a parent class: ({parent_op.input_type})." + ) + if op.output_type != parent_op.output_type: + raise TypeError( + f"Operation '{op_name}' in class '{user_class}' has output_type ({op.output_type}). " + f"This does not match the type of the same operation in a parent class: ({parent_op.output_type})." + ) + else: + merged[op_name] = parent_op + else: + merged[op_name] = op + return merged + @staticmethod def _collect_operations( user_class: Type[ServiceDefinitionT], @@ -261,4 +295,11 @@ def _collect_operations( if op.method_name is None: op.method_name = key - return {op.name: op for op in operations.values()} + 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/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 68f05f1..5745bc0 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -51,6 +51,17 @@ class A2(A1): expected_operation_names = {"a-name", "b-name"} +class TypeAnnotationWithInheritedInstance(_TestCase): + 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") From ef55dadffa16d9925a6a1bc5c7d841045a582b28 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 17 Jun 2025 18:41:14 -0400 Subject: [PATCH 046/178] Move Link and OperationInfo to top-level --- src/nexusrpc/__init__.py | 29 +++++++++++++++++++++++++++++ src/nexusrpc/handler/__init__.py | 6 ------ src/nexusrpc/handler/_common.py | 27 +-------------------------- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 6aa7adf..62751dd 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -1,3 +1,32 @@ +from dataclasses import dataclass +from enum import Enum + from ._service_definition import Operation as Operation from ._service_definition import ServiceDefinition as ServiceDefinition from ._service_definition import service as service + + +@dataclass +class Link: + """ + Link contains a URL and a Type that can be used to decode the URL. + Links can contain any arbitrary information as a percent-encoded URL. + It can be used to pass information about the caller to the handler, or vice versa. + """ + + # The URL must be percent-encoded. + url: str + # Can describe an actual data type for decoding the URL. Valid chars: alphanumeric, '_', '.', + # '/' + type: str + + +class OperationState(Enum): + """ + Describes the current state of an operation. + """ + + SUCCEEDED = "succeeded" + FAILED = "failed" + CANCELED = "canceled" + RUNNING = "running" diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 5135d6b..e87a493 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -22,9 +22,6 @@ from ._common import ( HandlerErrorType as HandlerErrorType, ) -from ._common import ( - Link as Link, -) from ._common import ( OperationContext as OperationContext, ) @@ -37,9 +34,6 @@ from ._common import ( OperationInfo as OperationInfo, ) -from ._common import ( - OperationState as OperationState, -) from ._common import ( StartOperationContext as StartOperationContext, ) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index bebffd2..fc1c30a 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -9,6 +9,7 @@ Sequence, ) +from nexusrpc import Link, OperationState from nexusrpc.types import OutputT @@ -84,21 +85,6 @@ def __init__(self, message: str, *, state: OperationErrorState): self.state = state -@dataclass -class Link: - """ - Link contains a URL and a Type that can be used to decode the URL. - Links can contain any arbitrary information as a percent-encoded URL. - It can be used to pass information about the caller to the handler, or vice versa. - """ - - # The URL must be percent-encoded. - url: str - # Can describe an actual data type for decoding the URL. Valid chars: alphanumeric, '_', '.', - # '/' - type: str - - @dataclass class OperationContext: """Context for the execution of the requested operation method. @@ -163,17 +149,6 @@ class FetchOperationResultContext(OperationContext): Includes information from the request.""" -class OperationState(Enum): - """ - Describes the current state of an operation. - """ - - SUCCEEDED = "succeeded" - FAILED = "failed" - CANCELED = "canceled" - RUNNING = "running" - - @dataclass class OperationInfo: """ From b632074f9ae20d2471da13453b0850d1efd0121a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 17 Jun 2025 18:45:21 -0400 Subject: [PATCH 047/178] Reorganize directory structure --- src/nexusrpc/__init__.py | 8 ++- .../{handler => _handler}/__init__.py | 18 ++--- src/nexusrpc/{handler => _handler}/_common.py | 0 src/nexusrpc/{handler => _handler}/_core.py | 6 +- .../{handler => _handler}/_decorators.py | 0 src/nexusrpc/{handler => _handler}/_util.py | 0 src/nexusrpc/{handler => }/_serializer.py | 0 .../{_service_definition.py => _service.py} | 0 src/nexusrpc/asyncio/__init__.py | 29 +++++++- src/nexusrpc/asyncio/_serializer.py | 27 ------- .../asyncio/{handler/_core.py => handler.py} | 17 +++-- src/nexusrpc/asyncio/handler/__init__.py | 10 --- src/nexusrpc/syncio/__init__.py | 35 +++++++++- src/nexusrpc/syncio/_serializer.py | 34 --------- .../syncio/{handler/_core.py => handler.py} | 14 ++-- src/nexusrpc/syncio/handler/__init__.py | 1 - tests/handler/test_forward_references.py | 2 +- tests/handler/test_handler_sync.py | 8 +-- ...er_validates_service_handler_collection.py | 20 +++--- ...collects_expected_operation_definitions.py | 54 +++++++------- ..._service_handler_decorator_requirements.py | 46 ++++++------ ...rrectly_functioning_operation_factories.py | 43 ++++++------ ..._decorator_selects_correct_service_name.py | 16 ++--- ...ator_validates_against_service_contract.py | 70 +++++++++---------- ...tor_validates_duplicate_operation_names.py | 12 ++-- ...test_service_handler_from_user_instance.py | 10 +-- ...corator_creates_valid_operation_handler.py | 24 ++++--- ..._decorator_selects_correct_service_name.py | 2 +- tests/test_get_input_and_output_types.py | 2 +- tests/test_util.py | 2 +- 30 files changed, 255 insertions(+), 255 deletions(-) rename src/nexusrpc/{handler => _handler}/__init__.py (95%) rename src/nexusrpc/{handler => _handler}/_common.py (100%) rename src/nexusrpc/{handler => _handler}/_core.py (99%) rename src/nexusrpc/{handler => _handler}/_decorators.py (100%) rename src/nexusrpc/{handler => _handler}/_util.py (100%) rename src/nexusrpc/{handler => }/_serializer.py (100%) rename src/nexusrpc/{_service_definition.py => _service.py} (100%) delete mode 100644 src/nexusrpc/asyncio/_serializer.py rename src/nexusrpc/asyncio/{handler/_core.py => handler.py} (88%) delete mode 100644 src/nexusrpc/asyncio/handler/__init__.py delete mode 100644 src/nexusrpc/syncio/_serializer.py rename src/nexusrpc/syncio/{handler/_core.py => handler.py} (92%) delete mode 100644 src/nexusrpc/syncio/handler/__init__.py diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 62751dd..4f03c2a 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -1,9 +1,11 @@ from dataclasses import dataclass from enum import Enum -from ._service_definition import Operation as Operation -from ._service_definition import ServiceDefinition as ServiceDefinition -from ._service_definition import service as service +from ._serializer import Content as Content +from ._serializer import LazyValue as LazyValue +from ._service import Operation as Operation +from ._service import ServiceDefinition as ServiceDefinition +from ._service import service as service @dataclass diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/_handler/__init__.py similarity index 95% rename from src/nexusrpc/handler/__init__.py rename to src/nexusrpc/_handler/__init__.py index e87a493..368072e 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/_handler/__init__.py @@ -7,6 +7,15 @@ from __future__ import annotations +from .._serializer import ( + Content as Content, +) +from .._serializer import ( + LazyValue as LazyValue, +) +from .._serializer import ( + Serializer as Serializer, +) from ._common import ( CancelOperationContext as CancelOperationContext, ) @@ -64,15 +73,6 @@ from ._decorators import ( sync_operation_handler as sync_operation_handler, ) -from ._serializer import ( - Content as Content, -) -from ._serializer import ( - LazyValue as LazyValue, -) -from ._serializer import ( - Serializer as Serializer, -) from ._util import ( get_start_method_input_and_output_types_annotations as get_start_method_input_and_output_types_annotations, ) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/_handler/_common.py similarity index 100% rename from src/nexusrpc/handler/_common.py rename to src/nexusrpc/_handler/_common.py diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/_handler/_core.py similarity index 99% rename from src/nexusrpc/handler/_core.py rename to src/nexusrpc/_handler/_core.py index ceb3ef3..1b7dda3 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/_handler/_core.py @@ -21,8 +21,8 @@ from typing_extensions import Self import nexusrpc -import nexusrpc._service_definition -from nexusrpc.handler._util import is_async_callable +import nexusrpc._service +from nexusrpc._handler._util import is_async_callable from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._common import ( @@ -36,7 +36,7 @@ StartOperationResultAsync, StartOperationResultSync, ) -from ._serializer import LazyValue +from .._serializer import LazyValue class BaseHandler(ABC): diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/_handler/_decorators.py similarity index 100% rename from src/nexusrpc/handler/_decorators.py rename to src/nexusrpc/_handler/_decorators.py diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/_handler/_util.py similarity index 100% rename from src/nexusrpc/handler/_util.py rename to src/nexusrpc/_handler/_util.py diff --git a/src/nexusrpc/handler/_serializer.py b/src/nexusrpc/_serializer.py similarity index 100% rename from src/nexusrpc/handler/_serializer.py rename to src/nexusrpc/_serializer.py diff --git a/src/nexusrpc/_service_definition.py b/src/nexusrpc/_service.py similarity index 100% rename from src/nexusrpc/_service_definition.py rename to src/nexusrpc/_service.py diff --git a/src/nexusrpc/asyncio/__init__.py b/src/nexusrpc/asyncio/__init__.py index 7d2ad61..72974c9 100644 --- a/src/nexusrpc/asyncio/__init__.py +++ b/src/nexusrpc/asyncio/__init__.py @@ -1,2 +1,29 @@ -from ._serializer import LazyValue as LazyValue +from typing import Any, AsyncIterable, Optional, Type + +import nexusrpc +from nexusrpc import Content + from .handler import Handler as Handler + + +class LazyValue(nexusrpc.LazyValue): + __doc__ = nexusrpc.LazyValue.__doc__ + stream: Optional[AsyncIterable[bytes]] + + async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + """ + Consume the underlying reader stream, deserializing via the embedded serializer. + """ + # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? + if self.stream is None: + return await self.serializer.deserialize( + Content(headers=self.headers), as_type=as_type + ) + + 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/asyncio/_serializer.py b/src/nexusrpc/asyncio/_serializer.py deleted file mode 100644 index 5fc75af..0000000 --- a/src/nexusrpc/asyncio/_serializer.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any, AsyncIterable, Optional, Type - -import nexusrpc.handler -from nexusrpc.handler._serializer import Content - - -class LazyValue(nexusrpc.handler.LazyValue): - __doc__ = nexusrpc.handler.LazyValue.__doc__ - stream: Optional[AsyncIterable[bytes]] - - async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: - """ - Consume the underlying reader stream, deserializing via the embedded serializer. - """ - # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? - if self.stream is None: - return await self.serializer.deserialize( - Content(headers=self.headers), as_type=as_type - ) - - 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/asyncio/handler/_core.py b/src/nexusrpc/asyncio/handler.py similarity index 88% rename from src/nexusrpc/asyncio/handler/_core.py rename to src/nexusrpc/asyncio/handler.py index 15d6005..c27a1f1 100644 --- a/src/nexusrpc/asyncio/handler/_core.py +++ b/src/nexusrpc/asyncio/handler.py @@ -1,3 +1,10 @@ +# TODO(preview): show what it looks like to manually build a service implementation at runtime +# where the operations may be based on some runtime information. + +# TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" +# TODO(preview): pass mypy + + from __future__ import annotations import inspect @@ -6,8 +13,8 @@ Union, ) -import nexusrpc.handler -from nexusrpc.handler import ( +import nexusrpc._handler +from nexusrpc._handler import ( CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, @@ -16,10 +23,10 @@ StartOperationResultAsync, StartOperationResultSync, ) -from nexusrpc.handler._util import is_async_callable +from nexusrpc._handler._util import is_async_callable -class Handler(nexusrpc.handler.BaseHandler): +class Handler(nexusrpc._handler.BaseHandler): """ A Nexus handler with `async def` methods. @@ -32,7 +39,7 @@ class Handler(nexusrpc.handler.BaseHandler): async def start_operation( self, ctx: StartOperationContext, - input: nexusrpc.handler.LazyValue, + input: nexusrpc._handler.LazyValue, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, diff --git a/src/nexusrpc/asyncio/handler/__init__.py b/src/nexusrpc/asyncio/handler/__init__.py deleted file mode 100644 index 8506bf0..0000000 --- a/src/nexusrpc/asyncio/handler/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO(preview): show what it looks like to manually build a service implementation at runtime -# where the operations may be based on some runtime information. - -# TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" -# TODO(preview): pass mypy - - -from __future__ import annotations - -from ._core import Handler as Handler diff --git a/src/nexusrpc/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py index fd0909b..0e75019 100644 --- a/src/nexusrpc/syncio/__init__.py +++ b/src/nexusrpc/syncio/__init__.py @@ -1 +1,34 @@ -from ._serializer import LazyValue as LazyValue +from __future__ import annotations + +from typing import ( + Any, + Iterable, + Optional, + Type, +) + +import nexusrpc._handler +from nexusrpc._handler import Content + + +class LazyValue(nexusrpc._handler.LazyValue): + __doc__ = nexusrpc._handler.LazyValue.__doc__ + stream: Optional[Iterable[bytes]] + + def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + """ + Consume the underlying reader stream, deserializing via the embedded serializer. + """ + # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? + if self.stream is None: + return self.serializer.deserialize( + Content(headers=self.headers), as_type=as_type + ) + + 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/_serializer.py b/src/nexusrpc/syncio/_serializer.py deleted file mode 100644 index 937811f..0000000 --- a/src/nexusrpc/syncio/_serializer.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from typing import ( - Any, - Iterable, - Optional, - Type, -) - -import nexusrpc.handler -from nexusrpc.handler import Content - - -class LazyValue(nexusrpc.handler.LazyValue): - __doc__ = nexusrpc.handler.LazyValue.__doc__ - stream: Optional[Iterable[bytes]] - - def consume(self, as_type: Optional[Type[Any]] = None) -> Any: - """ - Consume the underlying reader stream, deserializing via the embedded serializer. - """ - # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? - if self.stream is None: - return self.serializer.deserialize( - Content(headers=self.headers), as_type=as_type - ) - - 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/_core.py b/src/nexusrpc/syncio/handler.py similarity index 92% rename from src/nexusrpc/syncio/handler/_core.py rename to src/nexusrpc/syncio/handler.py index 9e226e0..23b5a9b 100644 --- a/src/nexusrpc/syncio/handler/_core.py +++ b/src/nexusrpc/syncio/handler.py @@ -5,22 +5,20 @@ Union, ) -import nexusrpc.handler -from nexusrpc.handler._common import ( +import nexusrpc._handler +from nexusrpc._handler import ( + CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, OperationInfo, -) -from nexusrpc.handler._core import ( - CancelOperationContext, StartOperationContext, StartOperationResultAsync, StartOperationResultSync, ) -from nexusrpc.handler._util import is_async_callable +from nexusrpc._handler._util import is_async_callable -class Handler(nexusrpc.handler.BaseHandler): +class Handler(nexusrpc._handler.BaseHandler): """ A Nexus handler with non-async `def` methods. @@ -33,7 +31,7 @@ class Handler(nexusrpc.handler.BaseHandler): def start_operation( self, ctx: StartOperationContext, - input: nexusrpc.handler.LazyValue, + input: nexusrpc._handler.LazyValue, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, diff --git a/src/nexusrpc/syncio/handler/__init__.py b/src/nexusrpc/syncio/handler/__init__.py deleted file mode 100644 index 777dab9..0000000 --- a/src/nexusrpc/syncio/handler/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ._core import Handler as Handler diff --git a/tests/handler/test_forward_references.py b/tests/handler/test_forward_references.py index 5e574dc..dc1577b 100644 --- a/tests/handler/test_forward_references.py +++ b/tests/handler/test_forward_references.py @@ -4,7 +4,7 @@ import pytest import nexusrpc -import nexusrpc.handler +import nexusrpc._handler @nexusrpc.service diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index 5bf4181..c47cef9 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -4,14 +4,14 @@ import pytest -from nexusrpc.handler import ( +from nexusrpc._handler import ( StartOperationContext, SyncExecutor, service_handler, ) -from nexusrpc.handler._common import StartOperationResultSync -from nexusrpc.handler._decorators import sync_operation_handler -from nexusrpc.handler._serializer import Content +from nexusrpc._handler._common import StartOperationResultSync +from nexusrpc._handler._decorators import sync_operation_handler +from nexusrpc._serializer import Content from nexusrpc.syncio import LazyValue from nexusrpc.syncio.handler import Handler diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index e94479f..df9a96a 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -5,7 +5,7 @@ import pytest -import nexusrpc.handler +import nexusrpc._handler from nexusrpc.asyncio.handler import Handler @@ -18,23 +18,23 @@ class Service: def test_services_are_collected(): - class OpHandler(nexusrpc.handler.SyncOperationHandler[int, int]): + class OpHandler(nexusrpc._handler.SyncOperationHandler[int, int]): async def start( self, - ctx: nexusrpc.handler.StartOperationContext, + ctx: nexusrpc._handler.StartOperationContext, input: int, - ) -> nexusrpc.handler.StartOperationResultSync[int]: ... + ) -> nexusrpc._handler.StartOperationResultSync[int]: ... async def cancel( self, - ctx: nexusrpc.handler.CancelOperationContext, + ctx: nexusrpc._handler.CancelOperationContext, token: str, ) -> None: ... - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class Service1: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[int, int]: + @nexusrpc._handler.operation_handler + def op(self) -> nexusrpc._handler.OperationHandler[int, int]: return OpHandler() service_handlers = Handler([Service1()]) @@ -46,11 +46,11 @@ def op(self) -> nexusrpc.handler.OperationHandler[int, int]: def test_service_names_must_be_unique(): - @nexusrpc.handler.service_handler(name="a") + @nexusrpc._handler.service_handler(name="a") class Service1: pass - @nexusrpc.handler.service_handler(name="a") + @nexusrpc._handler.service_handler(name="a") class Service2: pass 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 index afca58b..a6f9c57 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -8,8 +8,8 @@ import pytest -import nexusrpc._service_definition -import nexusrpc.handler +import nexusrpc._service +import nexusrpc._handler @dataclass @@ -30,10 +30,10 @@ class _TestCase: class ManualOperationHandler(_TestCase): - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class Service: - @nexusrpc.handler.operation_handler - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + @nexusrpc._handler.operation_handler + def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -46,10 +46,10 @@ def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... class ManualOperationHandlerWithNameOverride(_TestCase): - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class Service: - @nexusrpc.handler.operation_handler(name="operation-name") - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + @nexusrpc._handler.operation_handler(name="operation-name") + def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -62,11 +62,11 @@ def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... class SyncOperation(_TestCase): - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class Service: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler def sync_operation_handler( - self, ctx: nexusrpc.handler.StartOperationContext, input: Input + self, ctx: nexusrpc._handler.StartOperationContext, input: Input ) -> Output: ... expected_operations = { @@ -80,11 +80,11 @@ def sync_operation_handler( class SyncOperationWithOperationHandlerNameOverride(_TestCase): - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class Service: - @nexusrpc.handler.sync_operation_handler(name="sync-operation-name") + @nexusrpc._handler.sync_operation_handler(name="sync-operation-name") async def sync_operation_handler( - self, ctx: nexusrpc.handler.StartOperationContext, input: Input + self, ctx: nexusrpc._handler.StartOperationContext, input: Input ) -> Output: ... expected_operations = { @@ -102,10 +102,10 @@ class ManualOperationWithContract(_TestCase): class Contract: operation: nexusrpc.Operation[Input, Output] - @nexusrpc.handler.service_handler(service=Contract) + @nexusrpc._handler.service_handler(service=Contract) class Service: - @nexusrpc.handler.operation_handler - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + @nexusrpc._handler.operation_handler + def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -124,10 +124,10 @@ class Contract: name="operation-override", ) - @nexusrpc.handler.service_handler(service=Contract) + @nexusrpc._handler.service_handler(service=Contract) class Service: - @nexusrpc.handler.operation_handler(name="operation-override") - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + @nexusrpc._handler.operation_handler(name="operation-override") + def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -144,21 +144,23 @@ class SyncOperationWithCallableInstance(_TestCase): class Contract: sync_operation_with_callable_instance: nexusrpc.Operation[Input, Output] - @nexusrpc.handler.service_handler(service=Contract) + @nexusrpc._handler.service_handler(service=Contract) class Service: class sync_operation_with_callable_instance: def __call__( self, _handler: Any, - ctx: nexusrpc.handler.StartOperationContext, + ctx: nexusrpc._handler.StartOperationContext, input: Input, ) -> Output: ... # TODO(preview): improve the DX here. The decorator cannot be placed on the # callable class itself, because the user must be responsible for instantiating # the class to obtain the callable instance. - sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( # type: ignore - sync_operation_with_callable_instance() + sync_operation_with_callable_instance = ( + nexusrpc._handler.sync_operation_handler( # type: ignore + sync_operation_with_callable_instance() + ) ) expected_operations = { @@ -209,10 +211,10 @@ async def test_collected_operation_definitions( def test_operation_without_decorator(): class Service: - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... with pytest.warns( UserWarning, match=r"Did you forget to apply the @nexusrpc.handler.operation_handler decorator\?", ): - nexusrpc.handler.service_handler(Service) + nexusrpc._handler.service_handler(Service) diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index fadb93d..39dbfb1 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -6,9 +6,9 @@ import pytest import nexusrpc -import nexusrpc._service_definition -import nexusrpc.handler -from nexusrpc.handler._core import ServiceHandler +import nexusrpc._service +import nexusrpc._handler +from nexusrpc._handler._core import ServiceHandler # TODO(prerelease): check return type of op methods including fetch_result and fetch_info # temporalio.common._type_hints_from_func(hello_nexus.hello2().fetch_result), @@ -27,8 +27,8 @@ class UserService: op_B: nexusrpc.Operation[bool, float] class UserServiceHandler: - @nexusrpc.handler.operation_handler - def op_A(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + @nexusrpc._handler.operation_handler + def op_A(self) -> nexusrpc._handler.OperationHandler[int, str]: ... expected_error_message_pattern = r"does not implement operation 'op_B'" @@ -39,10 +39,10 @@ class UserService: op_A: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="foo") class UserServiceHandler: - @nexusrpc.handler.operation_handler + @nexusrpc._handler.operation_handler def op_A_incorrect_method_name( self, - ) -> nexusrpc.handler.OperationHandler[int, str]: ... + ) -> nexusrpc._handler.OperationHandler[int, str]: ... expected_error_message_pattern = ( r"does not match an operation method name in the service definition." @@ -60,7 +60,7 @@ def test_decorator_validates_definition_compliance( test_case: _DecoratorValidationTestCase, ): with pytest.raises(TypeError, match=test_case.expected_error_message_pattern): - nexusrpc.handler.service_handler(service=test_case.UserService)( + nexusrpc._handler.service_handler(service=test_case.UserService)( test_case.UserServiceHandler ) @@ -82,29 +82,29 @@ class UserService: base_op: nexusrpc.Operation[int, str] child_op: nexusrpc.Operation[bool, float] - @nexusrpc.handler.service_handler(service=BaseUserService) + @nexusrpc._handler.service_handler(service=BaseUserService) class BaseUserServiceHandler: - @nexusrpc.handler.operation_handler - def base_op(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + @nexusrpc._handler.operation_handler + def base_op(self) -> nexusrpc._handler.OperationHandler[int, str]: ... - @nexusrpc.handler.service_handler(service=UserService) + @nexusrpc._handler.service_handler(service=UserService) class UserServiceHandler(BaseUserServiceHandler): - @nexusrpc.handler.operation_handler - def child_op(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... + @nexusrpc._handler.operation_handler + def child_op(self) -> nexusrpc._handler.OperationHandler[bool, float]: ... expected_operations = {"base_op", "child_op"} class ServiceHandlerInheritanceWithoutDefinition(_ServiceHandlerInheritanceTestCase): - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class BaseUserServiceHandler: - @nexusrpc.handler.operation_handler - def base_op_nc(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + @nexusrpc._handler.operation_handler + def base_op_nc(self) -> nexusrpc._handler.OperationHandler[int, str]: ... - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class UserServiceHandler(BaseUserServiceHandler): - @nexusrpc.handler.operation_handler - def child_op_nc(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... + @nexusrpc._handler.operation_handler + def child_op_nc(self) -> nexusrpc._handler.OperationHandler[bool, float]: ... expected_operations = {"base_op_nc", "child_op_nc"} @@ -169,9 +169,9 @@ def test_service_definition_inheritance_behavior( TypeError, match="does not implement operation 'op_from_child_definition'" ): - @nexusrpc.handler.service_handler(service=test_case.UserService) + @nexusrpc._handler.service_handler(service=test_case.UserService) class HandlerMissingChildOp: - @nexusrpc.handler.operation_handler + @nexusrpc._handler.operation_handler def op_from_base_definition( self, - ) -> nexusrpc.handler.OperationHandler[int, str]: ... + ) -> nexusrpc._handler.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 index 83d5247..cecef90 100644 --- 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 @@ -7,11 +7,11 @@ import pytest -import nexusrpc._service_definition -import nexusrpc.handler +import nexusrpc._service +import nexusrpc._handler from nexusrpc.types import InputT, OutputT -from nexusrpc.handler._core import collect_operation_handler_factories -from nexusrpc.handler._util import is_async_callable +from nexusrpc._handler._core import collect_operation_handler_factories +from nexusrpc._handler._util import is_async_callable @dataclass @@ -21,15 +21,15 @@ class _TestCase: class ManualOperationDefinition(_TestCase): - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class Service: - @nexusrpc.handler.operation_handler - def operation(self) -> nexusrpc.handler.OperationHandler[int, int]: - class OpHandler(nexusrpc.handler.SyncOperationHandler[int, int]): + @nexusrpc._handler.operation_handler + def operation(self) -> nexusrpc._handler.OperationHandler[int, int]: + class OpHandler(nexusrpc._handler.SyncOperationHandler[int, int]): async def start( - self, ctx: nexusrpc.handler.StartOperationContext, input: int - ) -> nexusrpc.handler.StartOperationResultSync[int]: - return nexusrpc.handler.StartOperationResultSync(7) + self, ctx: nexusrpc._handler.StartOperationContext, input: int + ) -> nexusrpc._handler.StartOperationResultSync[int]: + return nexusrpc._handler.StartOperationResultSync(7) return OpHandler() @@ -37,11 +37,11 @@ async def start( class SyncOperation(_TestCase): - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class Service: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler def sync_operation_handler( - self, ctx: nexusrpc.handler.StartOperationContext, input: int + self, ctx: nexusrpc._handler.StartOperationContext, input: int ) -> int: return 7 @@ -68,29 +68,30 @@ async def test_collected_operation_factories_match_service_definition( test_case.Service, service ) assert operation_factories.keys() == test_case.expected_operation_factories.keys() - ctx = nexusrpc.handler.StartOperationContext( + ctx = nexusrpc._handler.StartOperationContext( service="Service", operation="operation", ) async def execute( - op: nexusrpc.handler.OperationHandler[InputT, OutputT], - ctx: nexusrpc.handler.StartOperationContext, + op: nexusrpc._handler.OperationHandler[InputT, OutputT], + ctx: nexusrpc._handler.StartOperationContext, input: InputT, ) -> Union[ - nexusrpc.handler.StartOperationResultSync[OutputT], - nexusrpc.handler.StartOperationResultAsync, + nexusrpc._handler.StartOperationResultSync[OutputT], + nexusrpc._handler.StartOperationResultAsync, ]: if is_async_callable(op.start): return await op.start(ctx, input) else: return cast( - nexusrpc.handler.StartOperationResultSync[OutputT], op.start(ctx, input) + nexusrpc._handler.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, nexusrpc.handler.StartOperationResultSync) + assert isinstance(result, nexusrpc._handler.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 index 59050e5..da0abc5 100644 --- a/tests/handler/test_service_handler_decorator_selects_correct_service_name.py +++ b/tests/handler/test_service_handler_decorator_selects_correct_service_name.py @@ -3,7 +3,7 @@ import pytest import nexusrpc -import nexusrpc.handler +import nexusrpc._handler @nexusrpc.service @@ -23,7 +23,7 @@ class _NameOverrideTestCase: class NotCalled(_NameOverrideTestCase): - @nexusrpc.handler.service_handler + @nexusrpc._handler.service_handler class ServiceImpl: pass @@ -31,7 +31,7 @@ class ServiceImpl: class CalledWithoutArgs(_NameOverrideTestCase): - @nexusrpc.handler.service_handler() + @nexusrpc._handler.service_handler() class ServiceImpl: pass @@ -39,7 +39,7 @@ class ServiceImpl: class CalledWithNameArg(_NameOverrideTestCase): - @nexusrpc.handler.service_handler(name="my-service-impl-🌈") + @nexusrpc._handler.service_handler(name="my-service-impl-🌈") class ServiceImpl: pass @@ -47,7 +47,7 @@ class ServiceImpl: class CalledWithInterface(_NameOverrideTestCase): - @nexusrpc.handler.service_handler(service=ServiceInterface) + @nexusrpc._handler.service_handler(service=ServiceInterface) class ServiceImpl: pass @@ -55,7 +55,7 @@ class ServiceImpl: class CalledWithInterfaceWithNameOverride(_NameOverrideTestCase): - @nexusrpc.handler.service_handler(service=ServiceInterfaceWithNameOverride) + @nexusrpc._handler.service_handler(service=ServiceInterfaceWithNameOverride) class ServiceImpl: pass @@ -80,11 +80,11 @@ def test_service_decorator_name_overrides(test_case: Type[_NameOverrideTestCase] def test_name_must_not_be_empty(): with pytest.raises(ValueError): - nexusrpc.handler.service_handler(name="")(object) + nexusrpc._handler.service_handler(name="")(object) def test_name_and_interface_are_mutually_exclusive(): with pytest.raises(ValueError): - nexusrpc.handler.service_handler( + nexusrpc._handler.service_handler( name="my-service-impl-🌈", service=ServiceInterface ) # type: ignore (enforced by overloads) 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 index bbbcd74..ce46642 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -3,7 +3,7 @@ import pytest import nexusrpc -import nexusrpc.handler +import nexusrpc._handler class _InterfaceImplementationTestCase: @@ -20,9 +20,9 @@ class Interface: def unrelated_method(self) -> None: ... class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: None + self, ctx: nexusrpc._handler.StartOperationContext, input: None ) -> None: ... error_message = None @@ -34,9 +34,9 @@ class Interface: pass class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def extra_op( - self, ctx: nexusrpc.handler.StartOperationContext, input: None + self, ctx: nexusrpc._handler.StartOperationContext, input: None ) -> None: ... def unrelated_method(self) -> None: ... @@ -50,7 +50,7 @@ class Interface: op: nexusrpc.Operation[int, str] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def op(self, ctx, input): ... error_message = None @@ -73,9 +73,9 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input + self, ctx: nexusrpc._handler.StartOperationContext, input ) -> None: ... error_message = None @@ -87,9 +87,9 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: str + self, ctx: nexusrpc._handler.StartOperationContext, input: str ) -> None: ... error_message = "is not compatible with the input type" @@ -101,9 +101,9 @@ class Interface: op: nexusrpc.Operation[None, int] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: None + self, ctx: nexusrpc._handler.StartOperationContext, input: None ) -> str: ... error_message = "is not compatible with the output type" @@ -115,9 +115,9 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: str + self, ctx: nexusrpc._handler.StartOperationContext, input: str ) -> str: ... error_message = "is not compatible with the output type" @@ -129,9 +129,9 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: str + self, ctx: nexusrpc._handler.StartOperationContext, input: str ) -> None: ... error_message = None @@ -143,9 +143,9 @@ class Interface: op: nexusrpc.Operation[Any, Any] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: str + self, ctx: nexusrpc._handler.StartOperationContext, input: str ) -> str: ... error_message = None @@ -169,8 +169,8 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @nexusrpc.handler.sync_operation_handler - def op(self, ctx: nexusrpc.handler.StartOperationContext, input: X) -> X: ... + @nexusrpc._handler.sync_operation_handler + def op(self, ctx: nexusrpc._handler.StartOperationContext, input: X) -> X: ... error_message = None @@ -181,9 +181,9 @@ class Interface: op: nexusrpc.Operation[X, SuperClass] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: X + self, ctx: nexusrpc._handler.StartOperationContext, input: X ) -> Subclass: ... error_message = None @@ -197,9 +197,9 @@ class Interface: op: nexusrpc.Operation[X, Subclass] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: X + self, ctx: nexusrpc._handler.StartOperationContext, input: X ) -> SuperClass: ... error_message = "is not compatible with the output type" @@ -211,8 +211,8 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @nexusrpc.handler.sync_operation_handler - def op(self, ctx: nexusrpc.handler.StartOperationContext, input: X) -> X: ... + @nexusrpc._handler.sync_operation_handler + def op(self, ctx: nexusrpc._handler.StartOperationContext, input: X) -> X: ... error_message = None @@ -223,9 +223,9 @@ class Interface: op: nexusrpc.Operation[Subclass, X] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: SuperClass + self, ctx: nexusrpc._handler.StartOperationContext, input: SuperClass ) -> X: ... error_message = None @@ -237,9 +237,9 @@ class Interface: op: nexusrpc.Operation[SuperClass, X] class Impl: - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: Subclass + self, ctx: nexusrpc._handler.StartOperationContext, input: Subclass ) -> X: ... error_message = "is not compatible with the input type" @@ -273,13 +273,13 @@ def test_service_decorator_enforces_interface_implementation( ): if test_case.error_message: with pytest.raises(Exception) as ei: - nexusrpc.handler.service_handler(service=test_case.Interface)( + nexusrpc._handler.service_handler(service=test_case.Interface)( test_case.Impl ) err = ei.value assert test_case.error_message in str(err) else: - nexusrpc.handler.service_handler(service=test_case.Interface)(test_case.Impl) + nexusrpc._handler.service_handler(service=test_case.Interface)(test_case.Impl) # TODO(preview): duplicate test? @@ -289,11 +289,11 @@ class Contract: operation_a: nexusrpc.Operation[None, None] class Service: - @nexusrpc.handler.operation_handler - def operation_b(self) -> nexusrpc.handler.OperationHandler[None, None]: ... + @nexusrpc._handler.operation_handler + def operation_b(self) -> nexusrpc._handler.OperationHandler[None, None]: ... with pytest.raises( TypeError, match="does not match an operation method name in the service definition", ): - nexusrpc.handler.service_handler(service=Contract)(Service) + nexusrpc._handler.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 index 0dae6fe..8f9c1c4 100644 --- a/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py +++ b/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py @@ -2,7 +2,7 @@ import pytest -import nexusrpc.handler +import nexusrpc._handler class _TestCase: @@ -12,12 +12,12 @@ class _TestCase: class DuplicateOperationName(_TestCase): class UserServiceHandler: - @nexusrpc.handler.operation_handler(name="a") - def op_1(self) -> nexusrpc.handler.OperationHandler[int, int]: ... + @nexusrpc._handler.operation_handler(name="a") + def op_1(self) -> nexusrpc._handler.OperationHandler[int, int]: ... - @nexusrpc.handler.sync_operation_handler(name="a") + @nexusrpc._handler.sync_operation_handler(name="a") def op_2( - self, ctx: nexusrpc.handler.StartOperationContext, input: str + self, ctx: nexusrpc._handler.StartOperationContext, input: str ) -> int: ... expected_error_message = ( @@ -33,4 +33,4 @@ def op_2( ) def test_service_handler_decorator(test_case: _TestCase): with pytest.raises(RuntimeError, match=test_case.expected_error_message): - nexusrpc.handler.service_handler(test_case.UserServiceHandler) + nexusrpc._handler.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 index 9578491..e8d28c6 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,23 +1,23 @@ from __future__ import annotations -import nexusrpc.handler -from nexusrpc.handler._core import ServiceHandler +import nexusrpc._handler +from nexusrpc._handler._core import ServiceHandler # TODO(preview): test operation_handler version of this -@nexusrpc.handler.service_handler +@nexusrpc._handler.service_handler class MyServiceHandlerWithCallableInstance: class SyncOperationWithCallableInstance: def __call__( self, _handler: MyServiceHandlerWithCallableInstance, - ctx: nexusrpc.handler.StartOperationContext, + ctx: nexusrpc._handler.StartOperationContext, input: int, ) -> int: return input - sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( + sync_operation_with_callable_instance = nexusrpc._handler.sync_operation_handler( name="sync_operation_with_callable_instance", )( SyncOperationWithCallableInstance(), 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 index f161ab0..437be3b 100644 --- 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 @@ -2,26 +2,28 @@ import pytest -import nexusrpc.handler -from nexusrpc.handler._util import is_async_callable +import nexusrpc._handler +from nexusrpc._handler._util import is_async_callable -@nexusrpc.handler.service_handler +@nexusrpc._handler.service_handler class MyServiceHandler: def __init__(self): self.mutable_container = [] - @nexusrpc.handler.sync_operation_handler - def my_def_op(self, ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: + @nexusrpc._handler.sync_operation_handler + def my_def_op( + self, ctx: nexusrpc._handler.StartOperationContext, input: int + ) -> int: """ This is the docstring for the `my_def_op` sync operation. """ self.mutable_container.append(input) return input + 1 - @nexusrpc.handler.sync_operation_handler + @nexusrpc._handler.sync_operation_handler async def my_async_def_op( - self, ctx: nexusrpc.handler.StartOperationContext, input: int + self, ctx: nexusrpc._handler.StartOperationContext, input: int ) -> int: """ This is the docstring for the `my_async_def_op` sync operation. @@ -39,9 +41,9 @@ def test_def_sync_handler(): == "This is the docstring for the `my_def_op` sync operation." ) assert not user_instance.mutable_container - ctx = mock.Mock(spec=nexusrpc.handler.StartOperationContext) + ctx = mock.Mock(spec=nexusrpc._handler.StartOperationContext) result = op_handler.start(ctx, 1) - assert isinstance(result, nexusrpc.handler.StartOperationResultSync) + assert isinstance(result, nexusrpc._handler.StartOperationResultSync) assert result.value == 2 assert user_instance.mutable_container == [1] @@ -56,8 +58,8 @@ async def test_async_def_sync_handler(): == "This is the docstring for the `my_async_def_op` sync operation." ) assert not user_instance.mutable_container - ctx = mock.Mock(spec=nexusrpc.handler.StartOperationContext) + ctx = mock.Mock(spec=nexusrpc._handler.StartOperationContext) result = await op_handler.start(ctx, 1) - assert isinstance(result, nexusrpc.handler.StartOperationResultSync) + assert isinstance(result, nexusrpc._handler.StartOperationResultSync) assert result.value == 3 assert user_instance.mutable_container == [1] 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 index f749dba..64e94c9 100644 --- a/tests/service_definition/test_service_decorator_selects_correct_service_name.py +++ b/tests/service_definition/test_service_decorator_selects_correct_service_name.py @@ -2,7 +2,7 @@ import pytest -import nexusrpc._service_definition +import nexusrpc._service class NameOverrideTestCase: diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index de4f837..5ac34b8 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -11,7 +11,7 @@ import pytest -from nexusrpc.handler import ( +from nexusrpc._handler import ( StartOperationContext, get_start_method_input_and_output_types_annotations, ) diff --git a/tests/test_util.py b/tests/test_util.py index 17f3d36..51bebfe 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,6 @@ from functools import partial -from nexusrpc.handler._util import is_async_callable +from nexusrpc._handler._util import is_async_callable def test_async_def_is_async_callable(): From 1df5c7ed2ad019877f190455a72ddebaa73367ad Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 18 Jun 2025 14:15:14 -0400 Subject: [PATCH 048/178] Revert handler rename --- src/nexusrpc/asyncio/handler.py | 10 +-- .../{_handler => handler}/__init__.py | 0 src/nexusrpc/{_handler => handler}/_common.py | 0 src/nexusrpc/{_handler => handler}/_core.py | 2 +- .../{_handler => handler}/_decorators.py | 0 src/nexusrpc/{_handler => handler}/_util.py | 0 src/nexusrpc/syncio/__init__.py | 8 +-- src/nexusrpc/syncio/handler.py | 10 +-- tests/handler/test_forward_references.py | 2 +- tests/handler/test_handler_sync.py | 6 +- ...er_validates_service_handler_collection.py | 20 +++--- ...collects_expected_operation_definitions.py | 52 +++++++------- ..._service_handler_decorator_requirements.py | 44 ++++++------ ...rrectly_functioning_operation_factories.py | 40 +++++------ ..._decorator_selects_correct_service_name.py | 16 ++--- ...ator_validates_against_service_contract.py | 70 +++++++++---------- ...tor_validates_duplicate_operation_names.py | 12 ++-- ...test_service_handler_from_user_instance.py | 10 +-- ...corator_creates_valid_operation_handler.py | 24 +++---- tests/test_get_input_and_output_types.py | 2 +- tests/test_util.py | 2 +- 21 files changed, 163 insertions(+), 167 deletions(-) rename src/nexusrpc/{_handler => handler}/__init__.py (100%) rename src/nexusrpc/{_handler => handler}/_common.py (100%) rename src/nexusrpc/{_handler => handler}/_core.py (99%) rename src/nexusrpc/{_handler => handler}/_decorators.py (100%) rename src/nexusrpc/{_handler => handler}/_util.py (100%) diff --git a/src/nexusrpc/asyncio/handler.py b/src/nexusrpc/asyncio/handler.py index c27a1f1..111e57e 100644 --- a/src/nexusrpc/asyncio/handler.py +++ b/src/nexusrpc/asyncio/handler.py @@ -13,8 +13,8 @@ Union, ) -import nexusrpc._handler -from nexusrpc._handler import ( +import nexusrpc.handler +from nexusrpc.handler import ( CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, @@ -23,10 +23,10 @@ StartOperationResultAsync, StartOperationResultSync, ) -from nexusrpc._handler._util import is_async_callable +from nexusrpc.handler._util import is_async_callable -class Handler(nexusrpc._handler.BaseHandler): +class Handler(nexusrpc.handler.BaseHandler): """ A Nexus handler with `async def` methods. @@ -39,7 +39,7 @@ class Handler(nexusrpc._handler.BaseHandler): async def start_operation( self, ctx: StartOperationContext, - input: nexusrpc._handler.LazyValue, + input: nexusrpc.handler.LazyValue, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, diff --git a/src/nexusrpc/_handler/__init__.py b/src/nexusrpc/handler/__init__.py similarity index 100% rename from src/nexusrpc/_handler/__init__.py rename to src/nexusrpc/handler/__init__.py diff --git a/src/nexusrpc/_handler/_common.py b/src/nexusrpc/handler/_common.py similarity index 100% rename from src/nexusrpc/_handler/_common.py rename to src/nexusrpc/handler/_common.py diff --git a/src/nexusrpc/_handler/_core.py b/src/nexusrpc/handler/_core.py similarity index 99% rename from src/nexusrpc/_handler/_core.py rename to src/nexusrpc/handler/_core.py index 1b7dda3..a34c6d5 100644 --- a/src/nexusrpc/_handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -22,7 +22,7 @@ import nexusrpc import nexusrpc._service -from nexusrpc._handler._util import is_async_callable +from nexusrpc.handler._util import is_async_callable from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._common import ( diff --git a/src/nexusrpc/_handler/_decorators.py b/src/nexusrpc/handler/_decorators.py similarity index 100% rename from src/nexusrpc/_handler/_decorators.py rename to src/nexusrpc/handler/_decorators.py diff --git a/src/nexusrpc/_handler/_util.py b/src/nexusrpc/handler/_util.py similarity index 100% rename from src/nexusrpc/_handler/_util.py rename to src/nexusrpc/handler/_util.py diff --git a/src/nexusrpc/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py index 0e75019..937811f 100644 --- a/src/nexusrpc/syncio/__init__.py +++ b/src/nexusrpc/syncio/__init__.py @@ -7,12 +7,12 @@ Type, ) -import nexusrpc._handler -from nexusrpc._handler import Content +import nexusrpc.handler +from nexusrpc.handler import Content -class LazyValue(nexusrpc._handler.LazyValue): - __doc__ = nexusrpc._handler.LazyValue.__doc__ +class LazyValue(nexusrpc.handler.LazyValue): + __doc__ = nexusrpc.handler.LazyValue.__doc__ stream: Optional[Iterable[bytes]] def consume(self, as_type: Optional[Type[Any]] = None) -> Any: diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index 23b5a9b..a66fbf4 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -5,8 +5,8 @@ Union, ) -import nexusrpc._handler -from nexusrpc._handler import ( +import nexusrpc.handler +from nexusrpc.handler import ( CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, @@ -15,10 +15,10 @@ StartOperationResultAsync, StartOperationResultSync, ) -from nexusrpc._handler._util import is_async_callable +from nexusrpc.handler._util import is_async_callable -class Handler(nexusrpc._handler.BaseHandler): +class Handler(nexusrpc.handler.BaseHandler): """ A Nexus handler with non-async `def` methods. @@ -31,7 +31,7 @@ class Handler(nexusrpc._handler.BaseHandler): def start_operation( self, ctx: StartOperationContext, - input: nexusrpc._handler.LazyValue, + input: nexusrpc.handler.LazyValue, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, diff --git a/tests/handler/test_forward_references.py b/tests/handler/test_forward_references.py index dc1577b..5e574dc 100644 --- a/tests/handler/test_forward_references.py +++ b/tests/handler/test_forward_references.py @@ -4,7 +4,7 @@ import pytest import nexusrpc -import nexusrpc._handler +import nexusrpc.handler @nexusrpc.service diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index c47cef9..6ffa110 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -4,13 +4,13 @@ import pytest -from nexusrpc._handler import ( +from nexusrpc.handler import ( StartOperationContext, SyncExecutor, service_handler, ) -from nexusrpc._handler._common import StartOperationResultSync -from nexusrpc._handler._decorators import sync_operation_handler +from nexusrpc.handler._common import StartOperationResultSync +from nexusrpc.handler._decorators import sync_operation_handler from nexusrpc._serializer import Content from nexusrpc.syncio import LazyValue from nexusrpc.syncio.handler import Handler diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index df9a96a..e94479f 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -5,7 +5,7 @@ import pytest -import nexusrpc._handler +import nexusrpc.handler from nexusrpc.asyncio.handler import Handler @@ -18,23 +18,23 @@ class Service: def test_services_are_collected(): - class OpHandler(nexusrpc._handler.SyncOperationHandler[int, int]): + class OpHandler(nexusrpc.handler.SyncOperationHandler[int, int]): async def start( self, - ctx: nexusrpc._handler.StartOperationContext, + ctx: nexusrpc.handler.StartOperationContext, input: int, - ) -> nexusrpc._handler.StartOperationResultSync[int]: ... + ) -> nexusrpc.handler.StartOperationResultSync[int]: ... async def cancel( self, - ctx: nexusrpc._handler.CancelOperationContext, + ctx: nexusrpc.handler.CancelOperationContext, token: str, ) -> None: ... - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class Service1: - @nexusrpc._handler.operation_handler - def op(self) -> nexusrpc._handler.OperationHandler[int, int]: + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[int, int]: return OpHandler() service_handlers = Handler([Service1()]) @@ -46,11 +46,11 @@ def op(self) -> nexusrpc._handler.OperationHandler[int, int]: def test_service_names_must_be_unique(): - @nexusrpc._handler.service_handler(name="a") + @nexusrpc.handler.service_handler(name="a") class Service1: pass - @nexusrpc._handler.service_handler(name="a") + @nexusrpc.handler.service_handler(name="a") class Service2: pass 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 index a6f9c57..3d1ed66 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -9,7 +9,7 @@ import pytest import nexusrpc._service -import nexusrpc._handler +import nexusrpc.handler @dataclass @@ -30,10 +30,10 @@ class _TestCase: class ManualOperationHandler(_TestCase): - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class Service: - @nexusrpc._handler.operation_handler - def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... + @nexusrpc.handler.operation_handler + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -46,10 +46,10 @@ def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... class ManualOperationHandlerWithNameOverride(_TestCase): - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class Service: - @nexusrpc._handler.operation_handler(name="operation-name") - def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... + @nexusrpc.handler.operation_handler(name="operation-name") + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -62,11 +62,11 @@ def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... class SyncOperation(_TestCase): - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class Service: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler def sync_operation_handler( - self, ctx: nexusrpc._handler.StartOperationContext, input: Input + self, ctx: nexusrpc.handler.StartOperationContext, input: Input ) -> Output: ... expected_operations = { @@ -80,11 +80,11 @@ def sync_operation_handler( class SyncOperationWithOperationHandlerNameOverride(_TestCase): - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class Service: - @nexusrpc._handler.sync_operation_handler(name="sync-operation-name") + @nexusrpc.handler.sync_operation_handler(name="sync-operation-name") async def sync_operation_handler( - self, ctx: nexusrpc._handler.StartOperationContext, input: Input + self, ctx: nexusrpc.handler.StartOperationContext, input: Input ) -> Output: ... expected_operations = { @@ -102,10 +102,10 @@ class ManualOperationWithContract(_TestCase): class Contract: operation: nexusrpc.Operation[Input, Output] - @nexusrpc._handler.service_handler(service=Contract) + @nexusrpc.handler.service_handler(service=Contract) class Service: - @nexusrpc._handler.operation_handler - def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... + @nexusrpc.handler.operation_handler + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -124,10 +124,10 @@ class Contract: name="operation-override", ) - @nexusrpc._handler.service_handler(service=Contract) + @nexusrpc.handler.service_handler(service=Contract) class Service: - @nexusrpc._handler.operation_handler(name="operation-override") - def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... + @nexusrpc.handler.operation_handler(name="operation-override") + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -144,23 +144,21 @@ class SyncOperationWithCallableInstance(_TestCase): class Contract: sync_operation_with_callable_instance: nexusrpc.Operation[Input, Output] - @nexusrpc._handler.service_handler(service=Contract) + @nexusrpc.handler.service_handler(service=Contract) class Service: class sync_operation_with_callable_instance: def __call__( self, _handler: Any, - ctx: nexusrpc._handler.StartOperationContext, + ctx: nexusrpc.handler.StartOperationContext, input: Input, ) -> Output: ... # TODO(preview): improve the DX here. The decorator cannot be placed on the # callable class itself, because the user must be responsible for instantiating # the class to obtain the callable instance. - sync_operation_with_callable_instance = ( - nexusrpc._handler.sync_operation_handler( # type: ignore - sync_operation_with_callable_instance() - ) + sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( # type: ignore + sync_operation_with_callable_instance() ) expected_operations = { @@ -211,10 +209,10 @@ async def test_collected_operation_definitions( def test_operation_without_decorator(): class Service: - def operation(self) -> nexusrpc._handler.OperationHandler[Input, Output]: ... + def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... with pytest.warns( UserWarning, match=r"Did you forget to apply the @nexusrpc.handler.operation_handler decorator\?", ): - nexusrpc._handler.service_handler(Service) + nexusrpc.handler.service_handler(Service) diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 39dbfb1..781b3b0 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -7,8 +7,8 @@ import nexusrpc import nexusrpc._service -import nexusrpc._handler -from nexusrpc._handler._core import ServiceHandler +import nexusrpc.handler +from nexusrpc.handler._core import ServiceHandler # TODO(prerelease): check return type of op methods including fetch_result and fetch_info # temporalio.common._type_hints_from_func(hello_nexus.hello2().fetch_result), @@ -27,8 +27,8 @@ class UserService: op_B: nexusrpc.Operation[bool, float] class UserServiceHandler: - @nexusrpc._handler.operation_handler - def op_A(self) -> nexusrpc._handler.OperationHandler[int, str]: ... + @nexusrpc.handler.operation_handler + def op_A(self) -> nexusrpc.handler.OperationHandler[int, str]: ... expected_error_message_pattern = r"does not implement operation 'op_B'" @@ -39,10 +39,10 @@ class UserService: op_A: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="foo") class UserServiceHandler: - @nexusrpc._handler.operation_handler + @nexusrpc.handler.operation_handler def op_A_incorrect_method_name( self, - ) -> nexusrpc._handler.OperationHandler[int, str]: ... + ) -> nexusrpc.handler.OperationHandler[int, str]: ... expected_error_message_pattern = ( r"does not match an operation method name in the service definition." @@ -60,7 +60,7 @@ def test_decorator_validates_definition_compliance( test_case: _DecoratorValidationTestCase, ): with pytest.raises(TypeError, match=test_case.expected_error_message_pattern): - nexusrpc._handler.service_handler(service=test_case.UserService)( + nexusrpc.handler.service_handler(service=test_case.UserService)( test_case.UserServiceHandler ) @@ -82,29 +82,29 @@ class UserService: base_op: nexusrpc.Operation[int, str] child_op: nexusrpc.Operation[bool, float] - @nexusrpc._handler.service_handler(service=BaseUserService) + @nexusrpc.handler.service_handler(service=BaseUserService) class BaseUserServiceHandler: - @nexusrpc._handler.operation_handler - def base_op(self) -> nexusrpc._handler.OperationHandler[int, str]: ... + @nexusrpc.handler.operation_handler + def base_op(self) -> nexusrpc.handler.OperationHandler[int, str]: ... - @nexusrpc._handler.service_handler(service=UserService) + @nexusrpc.handler.service_handler(service=UserService) class UserServiceHandler(BaseUserServiceHandler): - @nexusrpc._handler.operation_handler - def child_op(self) -> nexusrpc._handler.OperationHandler[bool, float]: ... + @nexusrpc.handler.operation_handler + def child_op(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... expected_operations = {"base_op", "child_op"} class ServiceHandlerInheritanceWithoutDefinition(_ServiceHandlerInheritanceTestCase): - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class BaseUserServiceHandler: - @nexusrpc._handler.operation_handler - def base_op_nc(self) -> nexusrpc._handler.OperationHandler[int, str]: ... + @nexusrpc.handler.operation_handler + def base_op_nc(self) -> nexusrpc.handler.OperationHandler[int, str]: ... - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class UserServiceHandler(BaseUserServiceHandler): - @nexusrpc._handler.operation_handler - def child_op_nc(self) -> nexusrpc._handler.OperationHandler[bool, float]: ... + @nexusrpc.handler.operation_handler + def child_op_nc(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... expected_operations = {"base_op_nc", "child_op_nc"} @@ -169,9 +169,9 @@ def test_service_definition_inheritance_behavior( TypeError, match="does not implement operation 'op_from_child_definition'" ): - @nexusrpc._handler.service_handler(service=test_case.UserService) + @nexusrpc.handler.service_handler(service=test_case.UserService) class HandlerMissingChildOp: - @nexusrpc._handler.operation_handler + @nexusrpc.handler.operation_handler def op_from_base_definition( self, - ) -> nexusrpc._handler.OperationHandler[int, str]: ... + ) -> nexusrpc.handler.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 index cecef90..12aa50c 100644 --- 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 @@ -8,10 +8,10 @@ import pytest import nexusrpc._service -import nexusrpc._handler +import nexusrpc.handler from nexusrpc.types import InputT, OutputT -from nexusrpc._handler._core import collect_operation_handler_factories -from nexusrpc._handler._util import is_async_callable +from nexusrpc.handler._core import collect_operation_handler_factories +from nexusrpc.handler._util import is_async_callable @dataclass @@ -21,15 +21,15 @@ class _TestCase: class ManualOperationDefinition(_TestCase): - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class Service: - @nexusrpc._handler.operation_handler - def operation(self) -> nexusrpc._handler.OperationHandler[int, int]: - class OpHandler(nexusrpc._handler.SyncOperationHandler[int, int]): + @nexusrpc.handler.operation_handler + def operation(self) -> nexusrpc.handler.OperationHandler[int, int]: + class OpHandler(nexusrpc.handler.SyncOperationHandler[int, int]): async def start( - self, ctx: nexusrpc._handler.StartOperationContext, input: int - ) -> nexusrpc._handler.StartOperationResultSync[int]: - return nexusrpc._handler.StartOperationResultSync(7) + self, ctx: nexusrpc.handler.StartOperationContext, input: int + ) -> nexusrpc.handler.StartOperationResultSync[int]: + return nexusrpc.handler.StartOperationResultSync(7) return OpHandler() @@ -37,11 +37,11 @@ async def start( class SyncOperation(_TestCase): - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class Service: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler def sync_operation_handler( - self, ctx: nexusrpc._handler.StartOperationContext, input: int + self, ctx: nexusrpc.handler.StartOperationContext, input: int ) -> int: return 7 @@ -68,24 +68,24 @@ async def test_collected_operation_factories_match_service_definition( test_case.Service, service ) assert operation_factories.keys() == test_case.expected_operation_factories.keys() - ctx = nexusrpc._handler.StartOperationContext( + ctx = nexusrpc.handler.StartOperationContext( service="Service", operation="operation", ) async def execute( - op: nexusrpc._handler.OperationHandler[InputT, OutputT], - ctx: nexusrpc._handler.StartOperationContext, + op: nexusrpc.handler.OperationHandler[InputT, OutputT], + ctx: nexusrpc.handler.StartOperationContext, input: InputT, ) -> Union[ - nexusrpc._handler.StartOperationResultSync[OutputT], - nexusrpc._handler.StartOperationResultAsync, + nexusrpc.handler.StartOperationResultSync[OutputT], + nexusrpc.handler.StartOperationResultAsync, ]: if is_async_callable(op.start): return await op.start(ctx, input) else: return cast( - nexusrpc._handler.StartOperationResultSync[OutputT], + nexusrpc.handler.StartOperationResultSync[OutputT], op.start(ctx, input), ) @@ -93,5 +93,5 @@ async def execute( op_factory = operation_factories[op_name] op = op_factory(test_case.Service) result = await execute(op, ctx, 0) - assert isinstance(result, nexusrpc._handler.StartOperationResultSync) + assert isinstance(result, nexusrpc.handler.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 index da0abc5..59050e5 100644 --- a/tests/handler/test_service_handler_decorator_selects_correct_service_name.py +++ b/tests/handler/test_service_handler_decorator_selects_correct_service_name.py @@ -3,7 +3,7 @@ import pytest import nexusrpc -import nexusrpc._handler +import nexusrpc.handler @nexusrpc.service @@ -23,7 +23,7 @@ class _NameOverrideTestCase: class NotCalled(_NameOverrideTestCase): - @nexusrpc._handler.service_handler + @nexusrpc.handler.service_handler class ServiceImpl: pass @@ -31,7 +31,7 @@ class ServiceImpl: class CalledWithoutArgs(_NameOverrideTestCase): - @nexusrpc._handler.service_handler() + @nexusrpc.handler.service_handler() class ServiceImpl: pass @@ -39,7 +39,7 @@ class ServiceImpl: class CalledWithNameArg(_NameOverrideTestCase): - @nexusrpc._handler.service_handler(name="my-service-impl-🌈") + @nexusrpc.handler.service_handler(name="my-service-impl-🌈") class ServiceImpl: pass @@ -47,7 +47,7 @@ class ServiceImpl: class CalledWithInterface(_NameOverrideTestCase): - @nexusrpc._handler.service_handler(service=ServiceInterface) + @nexusrpc.handler.service_handler(service=ServiceInterface) class ServiceImpl: pass @@ -55,7 +55,7 @@ class ServiceImpl: class CalledWithInterfaceWithNameOverride(_NameOverrideTestCase): - @nexusrpc._handler.service_handler(service=ServiceInterfaceWithNameOverride) + @nexusrpc.handler.service_handler(service=ServiceInterfaceWithNameOverride) class ServiceImpl: pass @@ -80,11 +80,11 @@ def test_service_decorator_name_overrides(test_case: Type[_NameOverrideTestCase] def test_name_must_not_be_empty(): with pytest.raises(ValueError): - nexusrpc._handler.service_handler(name="")(object) + nexusrpc.handler.service_handler(name="")(object) def test_name_and_interface_are_mutually_exclusive(): with pytest.raises(ValueError): - nexusrpc._handler.service_handler( + nexusrpc.handler.service_handler( name="my-service-impl-🌈", service=ServiceInterface ) # type: ignore (enforced by overloads) 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 index ce46642..bbbcd74 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -3,7 +3,7 @@ import pytest import nexusrpc -import nexusrpc._handler +import nexusrpc.handler class _InterfaceImplementationTestCase: @@ -20,9 +20,9 @@ class Interface: def unrelated_method(self) -> None: ... class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: None + self, ctx: nexusrpc.handler.StartOperationContext, input: None ) -> None: ... error_message = None @@ -34,9 +34,9 @@ class Interface: pass class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def extra_op( - self, ctx: nexusrpc._handler.StartOperationContext, input: None + self, ctx: nexusrpc.handler.StartOperationContext, input: None ) -> None: ... def unrelated_method(self) -> None: ... @@ -50,7 +50,7 @@ class Interface: op: nexusrpc.Operation[int, str] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def op(self, ctx, input): ... error_message = None @@ -73,9 +73,9 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def op( - self, ctx: nexusrpc._handler.StartOperationContext, input + self, ctx: nexusrpc.handler.StartOperationContext, input ) -> None: ... error_message = None @@ -87,9 +87,9 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: str + self, ctx: nexusrpc.handler.StartOperationContext, input: str ) -> None: ... error_message = "is not compatible with the input type" @@ -101,9 +101,9 @@ class Interface: op: nexusrpc.Operation[None, int] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: None + self, ctx: nexusrpc.handler.StartOperationContext, input: None ) -> str: ... error_message = "is not compatible with the output type" @@ -115,9 +115,9 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: str + self, ctx: nexusrpc.handler.StartOperationContext, input: str ) -> str: ... error_message = "is not compatible with the output type" @@ -129,9 +129,9 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: str + self, ctx: nexusrpc.handler.StartOperationContext, input: str ) -> None: ... error_message = None @@ -143,9 +143,9 @@ class Interface: op: nexusrpc.Operation[Any, Any] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: str + self, ctx: nexusrpc.handler.StartOperationContext, input: str ) -> str: ... error_message = None @@ -169,8 +169,8 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @nexusrpc._handler.sync_operation_handler - def op(self, ctx: nexusrpc._handler.StartOperationContext, input: X) -> X: ... + @nexusrpc.handler.sync_operation_handler + def op(self, ctx: nexusrpc.handler.StartOperationContext, input: X) -> X: ... error_message = None @@ -181,9 +181,9 @@ class Interface: op: nexusrpc.Operation[X, SuperClass] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: X + self, ctx: nexusrpc.handler.StartOperationContext, input: X ) -> Subclass: ... error_message = None @@ -197,9 +197,9 @@ class Interface: op: nexusrpc.Operation[X, Subclass] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: X + self, ctx: nexusrpc.handler.StartOperationContext, input: X ) -> SuperClass: ... error_message = "is not compatible with the output type" @@ -211,8 +211,8 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @nexusrpc._handler.sync_operation_handler - def op(self, ctx: nexusrpc._handler.StartOperationContext, input: X) -> X: ... + @nexusrpc.handler.sync_operation_handler + def op(self, ctx: nexusrpc.handler.StartOperationContext, input: X) -> X: ... error_message = None @@ -223,9 +223,9 @@ class Interface: op: nexusrpc.Operation[Subclass, X] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: SuperClass + self, ctx: nexusrpc.handler.StartOperationContext, input: SuperClass ) -> X: ... error_message = None @@ -237,9 +237,9 @@ class Interface: op: nexusrpc.Operation[SuperClass, X] class Impl: - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler def op( - self, ctx: nexusrpc._handler.StartOperationContext, input: Subclass + self, ctx: nexusrpc.handler.StartOperationContext, input: Subclass ) -> X: ... error_message = "is not compatible with the input type" @@ -273,13 +273,13 @@ def test_service_decorator_enforces_interface_implementation( ): if test_case.error_message: with pytest.raises(Exception) as ei: - nexusrpc._handler.service_handler(service=test_case.Interface)( + nexusrpc.handler.service_handler(service=test_case.Interface)( test_case.Impl ) err = ei.value assert test_case.error_message in str(err) else: - nexusrpc._handler.service_handler(service=test_case.Interface)(test_case.Impl) + nexusrpc.handler.service_handler(service=test_case.Interface)(test_case.Impl) # TODO(preview): duplicate test? @@ -289,11 +289,11 @@ class Contract: operation_a: nexusrpc.Operation[None, None] class Service: - @nexusrpc._handler.operation_handler - def operation_b(self) -> nexusrpc._handler.OperationHandler[None, None]: ... + @nexusrpc.handler.operation_handler + def operation_b(self) -> nexusrpc.handler.OperationHandler[None, None]: ... with pytest.raises( TypeError, match="does not match an operation method name in the service definition", ): - nexusrpc._handler.service_handler(service=Contract)(Service) + nexusrpc.handler.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 index 8f9c1c4..0dae6fe 100644 --- a/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py +++ b/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py @@ -2,7 +2,7 @@ import pytest -import nexusrpc._handler +import nexusrpc.handler class _TestCase: @@ -12,12 +12,12 @@ class _TestCase: class DuplicateOperationName(_TestCase): class UserServiceHandler: - @nexusrpc._handler.operation_handler(name="a") - def op_1(self) -> nexusrpc._handler.OperationHandler[int, int]: ... + @nexusrpc.handler.operation_handler(name="a") + def op_1(self) -> nexusrpc.handler.OperationHandler[int, int]: ... - @nexusrpc._handler.sync_operation_handler(name="a") + @nexusrpc.handler.sync_operation_handler(name="a") def op_2( - self, ctx: nexusrpc._handler.StartOperationContext, input: str + self, ctx: nexusrpc.handler.StartOperationContext, input: str ) -> int: ... expected_error_message = ( @@ -33,4 +33,4 @@ def op_2( ) def test_service_handler_decorator(test_case: _TestCase): with pytest.raises(RuntimeError, match=test_case.expected_error_message): - nexusrpc._handler.service_handler(test_case.UserServiceHandler) + nexusrpc.handler.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 index e8d28c6..9578491 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,23 +1,23 @@ from __future__ import annotations -import nexusrpc._handler -from nexusrpc._handler._core import ServiceHandler +import nexusrpc.handler +from nexusrpc.handler._core import ServiceHandler # TODO(preview): test operation_handler version of this -@nexusrpc._handler.service_handler +@nexusrpc.handler.service_handler class MyServiceHandlerWithCallableInstance: class SyncOperationWithCallableInstance: def __call__( self, _handler: MyServiceHandlerWithCallableInstance, - ctx: nexusrpc._handler.StartOperationContext, + ctx: nexusrpc.handler.StartOperationContext, input: int, ) -> int: return input - sync_operation_with_callable_instance = nexusrpc._handler.sync_operation_handler( + sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( name="sync_operation_with_callable_instance", )( SyncOperationWithCallableInstance(), 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 index 437be3b..f161ab0 100644 --- 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 @@ -2,28 +2,26 @@ import pytest -import nexusrpc._handler -from nexusrpc._handler._util import is_async_callable +import nexusrpc.handler +from nexusrpc.handler._util import is_async_callable -@nexusrpc._handler.service_handler +@nexusrpc.handler.service_handler class MyServiceHandler: def __init__(self): self.mutable_container = [] - @nexusrpc._handler.sync_operation_handler - def my_def_op( - self, ctx: nexusrpc._handler.StartOperationContext, input: int - ) -> int: + @nexusrpc.handler.sync_operation_handler + def my_def_op(self, ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: """ This is the docstring for the `my_def_op` sync operation. """ self.mutable_container.append(input) return input + 1 - @nexusrpc._handler.sync_operation_handler + @nexusrpc.handler.sync_operation_handler async def my_async_def_op( - self, ctx: nexusrpc._handler.StartOperationContext, input: int + self, ctx: nexusrpc.handler.StartOperationContext, input: int ) -> int: """ This is the docstring for the `my_async_def_op` sync operation. @@ -41,9 +39,9 @@ def test_def_sync_handler(): == "This is the docstring for the `my_def_op` sync operation." ) assert not user_instance.mutable_container - ctx = mock.Mock(spec=nexusrpc._handler.StartOperationContext) + ctx = mock.Mock(spec=nexusrpc.handler.StartOperationContext) result = op_handler.start(ctx, 1) - assert isinstance(result, nexusrpc._handler.StartOperationResultSync) + assert isinstance(result, nexusrpc.handler.StartOperationResultSync) assert result.value == 2 assert user_instance.mutable_container == [1] @@ -58,8 +56,8 @@ async def test_async_def_sync_handler(): == "This is the docstring for the `my_async_def_op` sync operation." ) assert not user_instance.mutable_container - ctx = mock.Mock(spec=nexusrpc._handler.StartOperationContext) + ctx = mock.Mock(spec=nexusrpc.handler.StartOperationContext) result = await op_handler.start(ctx, 1) - assert isinstance(result, nexusrpc._handler.StartOperationResultSync) + assert isinstance(result, nexusrpc.handler.StartOperationResultSync) assert result.value == 3 assert user_instance.mutable_container == [1] diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index 5ac34b8..de4f837 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -11,7 +11,7 @@ import pytest -from nexusrpc._handler import ( +from nexusrpc.handler import ( StartOperationContext, get_start_method_input_and_output_types_annotations, ) diff --git a/tests/test_util.py b/tests/test_util.py index 51bebfe..17f3d36 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,6 @@ from functools import partial -from nexusrpc._handler._util import is_async_callable +from nexusrpc.handler._util import is_async_callable def test_async_def_is_async_callable(): From cefb8fd060d4e771fe649179daed70eaa475ca9d Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 19 Jun 2025 12:25:52 -0400 Subject: [PATCH 049/178] Import order --- src/nexusrpc/handler/_core.py | 2 +- tests/handler/test_handler_sync.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index a34c6d5..1ea5a25 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -25,6 +25,7 @@ from nexusrpc.handler._util import is_async_callable from nexusrpc.types import InputT, OutputT, ServiceHandlerT +from .._serializer import LazyValue from ._common import ( CancelOperationContext, FetchOperationInfoContext, @@ -36,7 +37,6 @@ StartOperationResultAsync, StartOperationResultSync, ) -from .._serializer import LazyValue class BaseHandler(ABC): diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index 6ffa110..0fd8f96 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -4,6 +4,7 @@ import pytest +from nexusrpc._serializer import Content from nexusrpc.handler import ( StartOperationContext, SyncExecutor, @@ -11,7 +12,6 @@ ) from nexusrpc.handler._common import StartOperationResultSync from nexusrpc.handler._decorators import sync_operation_handler -from nexusrpc._serializer import Content from nexusrpc.syncio import LazyValue from nexusrpc.syncio.handler import Handler From 311eda4e81601612a38b3e3b874d7dcbef21af63 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 19 Jun 2025 12:24:50 -0400 Subject: [PATCH 050/178] Rename: SyncExecutor -> Executor --- src/nexusrpc/asyncio/handler.py | 8 ++++---- src/nexusrpc/handler/__init__.py | 4 ++-- src/nexusrpc/handler/_core.py | 14 ++++++-------- src/nexusrpc/syncio/handler.py | 10 ++++------ tests/handler/test_handler_sync.py | 4 ++-- 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/nexusrpc/asyncio/handler.py b/src/nexusrpc/asyncio/handler.py index 111e57e..9a41559 100644 --- a/src/nexusrpc/asyncio/handler.py +++ b/src/nexusrpc/asyncio/handler.py @@ -60,12 +60,12 @@ async def start_operation( return await op_handler.start(ctx, deserialized_input) else: # TODO(preview): apply middleware stack as composed functions - if not self.sync_executor: + if not self.executor: raise RuntimeError( "Operation start handler method is not an `async def` but " "no sync executor was provided to the Handler constructor. " ) - result = await self.sync_executor.submit_to_event_loop( + result = await self.executor.submit_to_event_loop( op_handler.start, ctx, deserialized_input ) if inspect.isawaitable(result): @@ -87,12 +87,12 @@ async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> Non if is_async_callable(op_handler.cancel): return await op_handler.cancel(ctx, token) else: - if not self.sync_executor: + if not self.executor: raise RuntimeError( "Operation cancel handler method is not an `async def` function but " "no executor was provided to the Handler constructor." ) - result = await self.sync_executor.submit_to_event_loop( + result = await self.executor.submit_to_event_loop( op_handler.cancel, ctx, token ) if inspect.isawaitable(result): diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 368072e..4fa3e9a 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -56,10 +56,10 @@ BaseHandler as BaseHandler, ) from ._core import ( - OperationHandler as OperationHandler, + Executor as Executor, ) from ._core import ( - SyncExecutor as SyncExecutor, + OperationHandler as OperationHandler, ) from ._core import ( SyncOperationHandler as SyncOperationHandler, diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 1ea5a25..cb669de 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -50,7 +50,7 @@ class BaseHandler(ABC): def __init__( self, user_service_handlers: Sequence[Any], - sync_executor: Optional[SyncExecutor] = None, + executor: Optional[Executor] = None, ): """Initialize a :py:class:`Handler` instance from user service handler instances. @@ -59,9 +59,9 @@ def __init__( Args: user_service_handlers: A sequence of user service handlers. - sync_executor: An executor to run non-`async def` operation handlers in. + executor: An executor to run non-`async def` operation handlers in. """ - self.sync_executor = sync_executor + self.executor = executor self.service_handlers = {} for sh in user_service_handlers: if isinstance(sh, type): @@ -78,7 +78,7 @@ def __init__( raise RuntimeError( f"Service '{sh.service.name}' has already been registered." ) - if self.sync_executor is None: + if self.executor is None: for op_name, operation_handler in sh.operation_handlers.items(): if not is_async_callable(operation_handler.start): raise RuntimeError( @@ -473,10 +473,8 @@ def service_from_operation_handler_methods( return nexusrpc.ServiceDefinition(name=service_name, operations=operations) -class SyncExecutor: - """ - Run a synchronous function in an executor. - """ +class Executor: + """An executor for synchronous functions.""" def __init__(self, executor: ThreadPoolExecutor): self._executor = executor diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index a66fbf4..d70de06 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -53,14 +53,12 @@ def start_operation( "cannot be called from a sync handler. " ) # TODO(preview): apply middleware stack as composed functions - if not self.sync_executor: + if not self.executor: raise RuntimeError( "Operation start handler method is not an `async def` but " "no sync executor was provided to the Handler constructor. " ) - return self.sync_executor.submit( - op_handler.start, ctx, deserialized_input - ).result() + 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. @@ -77,12 +75,12 @@ def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: "cannot be called from a sync handler. " ) else: - if not self.sync_executor: + if not self.executor: raise RuntimeError( "Operation cancel handler method is not an `async def` function but " "no executor was provided to the Handler constructor." ) - return self.sync_executor.submit(op_handler.cancel, ctx, token).result() + return self.executor.submit(op_handler.cancel, ctx, token).result() def fetch_operation_info( self, ctx: FetchOperationInfoContext, token: str diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index 0fd8f96..c88342c 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -6,8 +6,8 @@ from nexusrpc._serializer import Content from nexusrpc.handler import ( + Executor, StartOperationContext, - SyncExecutor, service_handler, ) from nexusrpc.handler._common import StartOperationResultSync @@ -34,7 +34,7 @@ def incr(self, ctx: StartOperationContext, input: int) -> int: def test_sync_handler_happy_path(test_case: Type[_TestCase]): handler = Handler( user_service_handlers=[test_case.user_service_handler], - sync_executor=SyncExecutor(executor=ThreadPoolExecutor(max_workers=1)), + executor=Executor(executor=ThreadPoolExecutor(max_workers=1)), ) ctx = StartOperationContext( service="MyService", From 0bd864bd750668d59a9541846a45bd776cb9a803 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 19 Jun 2025 12:42:14 -0400 Subject: [PATCH 051/178] TODO: Executor --- src/nexusrpc/handler/_core.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index cb669de..f45bd06 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -473,6 +473,22 @@ def service_from_operation_handler_methods( return nexusrpc.ServiceDefinition(name=service_name, operations=operations) +# TODO(prerelease): Do we definitely want to require users to create this wrapper? Two +# alternatives: +# +# 1. Require them to pass in a `concurrent.futures.Executor`. This is what +# `run_in_executor` is documented to require. This would mean that nexusrpc would +# initially have a hard-coded dependency on the asyncio event loop. But perhaps that +# is not a problem: if we ever want to support other event loops, we can add the +# ability to pass in an event loop implementation at the level of Handler. And in +# fact perhaps that's better than having the user choose their event loop once in +# their Executor, and also in other places in nexusrpc. +# https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor +# +# 2. Define an interface (typing.Protocol), containing `def submit(...)` and perhaps +# nothing else, and require them to pass in anything that implements the interface. +# But this seems dangerous/a non-starter: run_in_executor is documented to require a +# `concurrent.futures.Executor`, even if it is currently typed as taking Any. class Executor: """An executor for synchronous functions.""" From ca7af9af4ad716c31741991f87d665fa23b43885 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 19 Jun 2025 12:56:58 -0400 Subject: [PATCH 052/178] Do not require Executor wrapper --- src/nexusrpc/asyncio/handler.py | 2 +- src/nexusrpc/handler/__init__.py | 3 --- src/nexusrpc/handler/_core.py | 21 +++++++++++++-------- src/nexusrpc/syncio/handler.py | 2 +- tests/handler/test_handler_sync.py | 3 +-- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/nexusrpc/asyncio/handler.py b/src/nexusrpc/asyncio/handler.py index 9a41559..536dcaf 100644 --- a/src/nexusrpc/asyncio/handler.py +++ b/src/nexusrpc/asyncio/handler.py @@ -63,7 +63,7 @@ async def start_operation( if not self.executor: raise RuntimeError( "Operation start handler method is not an `async def` but " - "no sync executor was provided to the Handler constructor. " + "no executor was provided to the Handler constructor. " ) result = await self.executor.submit_to_event_loop( op_handler.start, ctx, deserialized_input diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 4fa3e9a..48dae54 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -55,9 +55,6 @@ from ._core import ( BaseHandler as BaseHandler, ) -from ._core import ( - Executor as Executor, -) from ._core import ( OperationHandler as OperationHandler, ) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index f45bd06..201cc97 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -1,11 +1,11 @@ from __future__ import annotations import asyncio +import concurrent.futures import inspect import typing import warnings from abc import ABC, abstractmethod -from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass from typing import ( Any, @@ -50,7 +50,7 @@ class BaseHandler(ABC): def __init__( self, user_service_handlers: Sequence[Any], - executor: Optional[Executor] = None, + executor: Optional[concurrent.futures.Executor] = None, ): """Initialize a :py:class:`Handler` instance from user service handler instances. @@ -59,9 +59,9 @@ def __init__( Args: user_service_handlers: A sequence of user service handlers. - executor: An executor to run non-`async def` operation handlers in. + executor: A concurrent.futures.Executor in which to run non-`async def` operation handlers. """ - self.executor = executor + self.executor = _Executor(executor) if executor else None self.service_handlers = {} for sh in user_service_handlers: if isinstance(sh, type): @@ -473,7 +473,7 @@ def service_from_operation_handler_methods( return nexusrpc.ServiceDefinition(name=service_name, operations=operations) -# TODO(prerelease): Do we definitely want to require users to create this wrapper? Two +# TODO(prerelease): Do we want to require users to create this wrapper? Two # alternatives: # # 1. Require them to pass in a `concurrent.futures.Executor`. This is what @@ -489,10 +489,13 @@ def service_from_operation_handler_methods( # nothing else, and require them to pass in anything that implements the interface. # But this seems dangerous/a non-starter: run_in_executor is documented to require a # `concurrent.futures.Executor`, even if it is currently typed as taking Any. -class Executor: +# +# I've switched to alternative (1). The following class is no longer in the public API +# of nexusrpc. +class _Executor: """An executor for synchronous functions.""" - def __init__(self, executor: ThreadPoolExecutor): + def __init__(self, executor: concurrent.futures.Executor): self._executor = executor def submit_to_event_loop( @@ -500,5 +503,7 @@ def submit_to_event_loop( ) -> Awaitable[Any]: return asyncio.get_event_loop().run_in_executor(self._executor, fn, *args) - def submit(self, fn: Callable[..., Any], *args: Any) -> Future[Any]: + def submit( + self, fn: Callable[..., Any], *args: Any + ) -> concurrent.futures.Future[Any]: return self._executor.submit(fn, *args) diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index d70de06..32c7ce0 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -56,7 +56,7 @@ def start_operation( if not self.executor: raise RuntimeError( "Operation start handler method is not an `async def` but " - "no sync executor was provided to the Handler constructor. " + "no executor was provided to the Handler constructor. " ) return self.executor.submit(op_handler.start, ctx, deserialized_input).result() diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index c88342c..a497634 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -6,7 +6,6 @@ from nexusrpc._serializer import Content from nexusrpc.handler import ( - Executor, StartOperationContext, service_handler, ) @@ -34,7 +33,7 @@ def incr(self, ctx: StartOperationContext, input: int) -> int: def test_sync_handler_happy_path(test_case: Type[_TestCase]): handler = Handler( user_service_handlers=[test_case.user_service_handler], - executor=Executor(executor=ThreadPoolExecutor(max_workers=1)), + executor=ThreadPoolExecutor(max_workers=1), ) ctx = StartOperationContext( service="MyService", From e4652debc42ac57a1cfa666c6da2ae0e27856ba1 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 19 Jun 2025 13:15:22 -0400 Subject: [PATCH 053/178] Rename with Sync/Async suffixes --- src/nexusrpc/__init__.py | 3 +- src/nexusrpc/_serializer.py | 47 +++++ src/nexusrpc/asyncio/__init__.py | 29 --- src/nexusrpc/asyncio/handler.py | 113 ------------ src/nexusrpc/handler/__init__.py | 5 +- src/nexusrpc/handler/_core.py | 170 +++++++++++++++++- src/nexusrpc/syncio/__init__.py | 34 ---- src/nexusrpc/syncio/handler.py | 93 ---------- tests/handler/test_handler_sync.py | 8 +- ...er_validates_service_handler_collection.py | 8 +- 10 files changed, 230 insertions(+), 280 deletions(-) delete mode 100644 src/nexusrpc/asyncio/__init__.py delete mode 100644 src/nexusrpc/asyncio/handler.py delete mode 100644 src/nexusrpc/syncio/__init__.py delete mode 100644 src/nexusrpc/syncio/handler.py diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 4f03c2a..352f5f2 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -2,7 +2,8 @@ from enum import Enum from ._serializer import Content as Content -from ._serializer import LazyValue as LazyValue +from ._serializer import LazyValueAsync as LazyValueAsync +from ._serializer import LazyValueSync as LazyValueSync from ._service import Operation as Operation from ._service import ServiceDefinition as ServiceDefinition from ._service import service as service diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index 97bf064..baaa5ac 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -63,6 +63,7 @@ def deserialize( class LazyValue(ABC): """ 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. """ @@ -92,3 +93,49 @@ def consume( Consume the underlying reader stream, deserializing via the embedded serializer. """ ... + + +class LazyValueSync(LazyValue): + __doc__ = LazyValue.__doc__ + stream: Optional[Iterable[bytes]] + + def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + """ + Consume the underlying reader stream, deserializing via the embedded serializer. + """ + # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? + if self.stream is None: + return self.serializer.deserialize( + Content(headers=self.headers), as_type=as_type + ) + + return self.serializer.deserialize( + Content( + headers=self.headers, + data=b"".join([c for c in self.stream]), + ), + as_type=as_type, + ) + + +class LazyValueAsync(LazyValue): + __doc__ = LazyValue.__doc__ + stream: Optional[AsyncIterable[bytes]] + + async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + """ + Consume the underlying reader stream, deserializing via the embedded serializer. + """ + # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? + if self.stream is None: + return await self.serializer.deserialize( + Content(headers=self.headers), as_type=as_type + ) + + 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/asyncio/__init__.py b/src/nexusrpc/asyncio/__init__.py deleted file mode 100644 index 72974c9..0000000 --- a/src/nexusrpc/asyncio/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Any, AsyncIterable, Optional, Type - -import nexusrpc -from nexusrpc import Content - -from .handler import Handler as Handler - - -class LazyValue(nexusrpc.LazyValue): - __doc__ = nexusrpc.LazyValue.__doc__ - stream: Optional[AsyncIterable[bytes]] - - async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: - """ - Consume the underlying reader stream, deserializing via the embedded serializer. - """ - # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? - if self.stream is None: - return await self.serializer.deserialize( - Content(headers=self.headers), as_type=as_type - ) - - 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/asyncio/handler.py b/src/nexusrpc/asyncio/handler.py deleted file mode 100644 index 536dcaf..0000000 --- a/src/nexusrpc/asyncio/handler.py +++ /dev/null @@ -1,113 +0,0 @@ -# TODO(preview): show what it looks like to manually build a service implementation at runtime -# where the operations may be based on some runtime information. - -# TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" -# TODO(preview): pass mypy - - -from __future__ import annotations - -import inspect -from typing import ( - Any, - Union, -) - -import nexusrpc.handler -from nexusrpc.handler import ( - CancelOperationContext, - FetchOperationInfoContext, - FetchOperationResultContext, - OperationInfo, - StartOperationContext, - StartOperationResultAsync, - StartOperationResultSync, -) -from nexusrpc.handler._util import is_async_callable - - -class Handler(nexusrpc.handler.BaseHandler): - """ - A Nexus handler with `async def` methods. - - A Nexus handler manages a collection of Nexus service handlers. - - Operation requests are delegated to a :py:class:`ServiceHandler` based on the service - name in the operation context. - """ - - async def start_operation( - self, - ctx: StartOperationContext, - input: nexusrpc.handler.LazyValue, - ) -> 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) - - if is_async_callable(op_handler.start): - # TODO(preview): apply middleware stack as composed awaitables - return await op_handler.start(ctx, deserialized_input) - else: - # TODO(preview): apply middleware stack as composed functions - if not self.executor: - raise RuntimeError( - "Operation start handler method is not an `async def` but " - "no executor was provided to the Handler constructor. " - ) - result = await self.executor.submit_to_event_loop( - op_handler.start, ctx, deserialized_input - ) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation start handler method {op_handler.start} returned an " - "awaitable but is not an `async def` coroutine function." - ) - return result - - 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: - if not self.executor: - raise RuntimeError( - "Operation cancel handler method is not an `async def` function but " - "no executor was provided to the Handler constructor." - ) - result = await self.executor.submit_to_event_loop( - op_handler.cancel, ctx, token - ) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation cancel handler method {op_handler.cancel} returned an " - "awaitable but is not an `async def` function." - ) - return result - - async def fetch_operation_info( - self, ctx: FetchOperationInfoContext, token: str - ) -> OperationInfo: - raise NotImplementedError - - async def fetch_operation_result( - self, ctx: FetchOperationResultContext, token: str - ) -> Any: - raise NotImplementedError diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 48dae54..818bacc 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -53,7 +53,10 @@ StartOperationResultSync as StartOperationResultSync, ) from ._core import ( - BaseHandler as BaseHandler, + HandlerAsync as HandlerAsync, +) +from ._core import ( + HandlerSync as HandlerSync, ) from ._core import ( OperationHandler as OperationHandler, diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 201cc97..7b62405 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -25,7 +25,7 @@ from nexusrpc.handler._util import is_async_callable from nexusrpc.types import InputT, OutputT, ServiceHandlerT -from .._serializer import LazyValue +from .._serializer import LazyValue, LazyValueAsync, LazyValueSync from ._common import ( CancelOperationContext, FetchOperationInfoContext, @@ -38,6 +38,12 @@ StartOperationResultSync, ) +# TODO(preview): show what it looks like to manually build a service implementation at runtime +# where the operations may be based on some runtime information. + +# TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" +# TODO(preview): pass mypy + class BaseHandler(ABC): """ @@ -149,6 +155,168 @@ def _get_service_handler(self, service_name: str) -> ServiceHandler: return service +class HandlerSync(BaseHandler): + """ + A Nexus handler with non-async `def` methods. + + A Nexus handler manages 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 start_operation( + self, + ctx: StartOperationContext, + input: LazyValueSync, + ) -> 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) + + if is_async_callable(op_handler.start): + raise RuntimeError( + "Operation start handler method is an `async def` and " + "cannot be called from a sync handler. " + ) + # TODO(preview): apply middleware stack as composed functions + if not self.executor: + raise RuntimeError( + "Operation start handler method is not an `async def` but " + "no executor was provided to the Handler constructor. " + ) + 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) + if is_async_callable(op_handler.cancel): + raise RuntimeError( + "Operation cancel handler method is an `async def` and " + "cannot be called from a sync handler. " + ) + else: + if not self.executor: + raise RuntimeError( + "Operation cancel handler method is not an `async def` function 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 + + +class HandlerAsync(BaseHandler): + """ + A Nexus handler with `async def` methods. + + A Nexus handler manages a collection of Nexus service handlers. + + Operation requests are delegated to a :py:class:`ServiceHandler` based on the service + name in the operation context. + """ + + async def start_operation( + self, + ctx: StartOperationContext, + input: LazyValueAsync, + ) -> 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) + + if is_async_callable(op_handler.start): + # TODO(preview): apply middleware stack as composed awaitables + return await op_handler.start(ctx, deserialized_input) + else: + # TODO(preview): apply middleware stack as composed functions + if not self.executor: + raise RuntimeError( + "Operation start handler method is not an `async def` but " + "no executor was provided to the Handler constructor. " + ) + result = await self.executor.submit_to_event_loop( + op_handler.start, ctx, deserialized_input + ) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation start handler method {op_handler.start} returned an " + "awaitable but is not an `async def` coroutine function." + ) + return result + + 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: + if not self.executor: + raise RuntimeError( + "Operation cancel handler method is not an `async def` function but " + "no executor was provided to the Handler constructor." + ) + result = await self.executor.submit_to_event_loop( + op_handler.cancel, ctx, token + ) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation cancel handler method {op_handler.cancel} returned an " + "awaitable but is not an `async def` function." + ) + return result + + async def fetch_operation_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> OperationInfo: + raise NotImplementedError + + async def fetch_operation_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Any: + raise NotImplementedError + + @dataclass class ServiceHandler: """Internal representation of a user's Nexus service implementation instance. diff --git a/src/nexusrpc/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py deleted file mode 100644 index 937811f..0000000 --- a/src/nexusrpc/syncio/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from typing import ( - Any, - Iterable, - Optional, - Type, -) - -import nexusrpc.handler -from nexusrpc.handler import Content - - -class LazyValue(nexusrpc.handler.LazyValue): - __doc__ = nexusrpc.handler.LazyValue.__doc__ - stream: Optional[Iterable[bytes]] - - def consume(self, as_type: Optional[Type[Any]] = None) -> Any: - """ - Consume the underlying reader stream, deserializing via the embedded serializer. - """ - # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? - if self.stream is None: - return self.serializer.deserialize( - Content(headers=self.headers), as_type=as_type - ) - - 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.py b/src/nexusrpc/syncio/handler.py deleted file mode 100644 index 32c7ce0..0000000 --- a/src/nexusrpc/syncio/handler.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from typing import ( - Any, - Union, -) - -import nexusrpc.handler -from nexusrpc.handler import ( - CancelOperationContext, - FetchOperationInfoContext, - FetchOperationResultContext, - OperationInfo, - StartOperationContext, - StartOperationResultAsync, - StartOperationResultSync, -) -from nexusrpc.handler._util import is_async_callable - - -class Handler(nexusrpc.handler.BaseHandler): - """ - A Nexus handler with non-async `def` methods. - - A Nexus handler manages 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 start_operation( - self, - ctx: StartOperationContext, - input: nexusrpc.handler.LazyValue, - ) -> 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) - - if is_async_callable(op_handler.start): - raise RuntimeError( - "Operation start handler method is an `async def` and " - "cannot be called from a sync handler. " - ) - # TODO(preview): apply middleware stack as composed functions - if not self.executor: - raise RuntimeError( - "Operation start handler method is not an `async def` but " - "no executor was provided to the Handler constructor. " - ) - 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) - if is_async_callable(op_handler.cancel): - raise RuntimeError( - "Operation cancel handler method is an `async def` and " - "cannot be called from a sync handler. " - ) - else: - if not self.executor: - raise RuntimeError( - "Operation cancel handler method is not an `async def` function 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 diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index a497634..e5c23b9 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -4,6 +4,7 @@ import pytest +from nexusrpc import LazyValueSync from nexusrpc._serializer import Content from nexusrpc.handler import ( StartOperationContext, @@ -11,8 +12,7 @@ ) from nexusrpc.handler._common import StartOperationResultSync from nexusrpc.handler._decorators import sync_operation_handler -from nexusrpc.syncio import LazyValue -from nexusrpc.syncio.handler import Handler +from nexusrpc.handler import HandlerSync class _TestCase: @@ -31,7 +31,7 @@ def incr(self, ctx: StartOperationContext, input: int) -> int: @pytest.mark.parametrize("test_case", [SyncHandlerHappyPath]) def test_sync_handler_happy_path(test_case: Type[_TestCase]): - handler = Handler( + handler = HandlerSync( user_service_handlers=[test_case.user_service_handler], executor=ThreadPoolExecutor(max_workers=1), ) @@ -39,7 +39,7 @@ def test_sync_handler_happy_path(test_case: Type[_TestCase]): service="MyService", operation="incr", ) - result = handler.start_operation(ctx, LazyValue(DummySerializer(1), headers={})) + result = handler.start_operation(ctx, LazyValueSync(DummySerializer(1), headers={})) assert isinstance(result, StartOperationResultSync) assert result.value == 2 diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index e94479f..35d4cdd 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -6,7 +6,7 @@ import pytest import nexusrpc.handler -from nexusrpc.asyncio.handler import Handler +from nexusrpc.handler import HandlerAsync def test_service_must_use_decorator(): @@ -14,7 +14,7 @@ class Service: pass with pytest.raises(RuntimeError): - Handler([Service()]) + HandlerAsync([Service()]) def test_services_are_collected(): @@ -37,7 +37,7 @@ class Service1: def op(self) -> nexusrpc.handler.OperationHandler[int, int]: return OpHandler() - service_handlers = Handler([Service1()]) + service_handlers = HandlerAsync([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() == { @@ -55,4 +55,4 @@ class Service2: pass with pytest.raises(RuntimeError): - Handler([Service1(), Service2()]) + HandlerAsync([Service1(), Service2()]) From 5b659d91e5f6696eb8632152e079a5e045dc46aa Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 19 Jun 2025 13:27:46 -0400 Subject: [PATCH 054/178] Reorganize --- src/nexusrpc/handler/__init__.py | 27 +- src/nexusrpc/handler/_core.py | 263 +------------------ src/nexusrpc/handler/_decorators.py | 2 +- src/nexusrpc/handler/_operation_handler.py | 283 +++++++++++++++++++++ 4 files changed, 297 insertions(+), 278 deletions(-) create mode 100644 src/nexusrpc/handler/_operation_handler.py diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 818bacc..250381b 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -4,18 +4,12 @@ # TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" # TODO(preview): pass mypy +# TODO(prerelease): docstrings +# TODO(prerelease): check API docs + from __future__ import annotations -from .._serializer import ( - Content as Content, -) -from .._serializer import ( - LazyValue as LazyValue, -) -from .._serializer import ( - Serializer as Serializer, -) from ._common import ( CancelOperationContext as CancelOperationContext, ) @@ -58,12 +52,6 @@ from ._core import ( HandlerSync as HandlerSync, ) -from ._core import ( - OperationHandler as OperationHandler, -) -from ._core import ( - SyncOperationHandler as SyncOperationHandler, -) from ._decorators import ( operation_handler as operation_handler, ) @@ -73,9 +61,12 @@ from ._decorators import ( sync_operation_handler as sync_operation_handler, ) +from ._operation_handler import ( + OperationHandler as OperationHandler, +) +from ._operation_handler import ( + SyncOperationHandler as SyncOperationHandler, +) from ._util import ( get_start_method_input_and_output_types_annotations as get_start_method_input_and_output_types_annotations, ) - -# TODO(prerelease): docstrings -# TODO(prerelease): check API docs diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 7b62405..d538dcd 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -3,18 +3,14 @@ import asyncio import concurrent.futures import inspect -import typing -import warnings from abc import ABC, abstractmethod from dataclasses import dataclass from typing import ( Any, Awaitable, Callable, - Generic, Optional, Sequence, - Type, Union, ) @@ -23,7 +19,6 @@ import nexusrpc import nexusrpc._service from nexusrpc.handler._util import is_async_callable -from nexusrpc.types import InputT, OutputT, ServiceHandlerT from .._serializer import LazyValue, LazyValueAsync, LazyValueSync from ._common import ( @@ -37,6 +32,10 @@ StartOperationResultAsync, StartOperationResultSync, ) +from ._operation_handler import ( + OperationHandler, + collect_operation_handler_factories, +) # TODO(preview): show what it looks like to manually build a service implementation at runtime # where the operations may be based on some runtime information. @@ -387,260 +386,6 @@ def _get_operation_handler(self, operation: str) -> OperationHandler[Any, Any]: return operation_handler -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. - - Alternatively, to create an operation handler that is limited to returning - synchronously, create the start method of the :py:class:`OperationHandler` on your - service handler class and apply the - :py:func:`@nexusrpc.handler.sync_operation_handler` decorator. - """ - - # TODO(preview): We are using `def` signatures with union return types in this abstract - # base class to represent both `def` and `async` def implementations in child classes. - # However, this causes VSCode to autocomplete the methods with non-sensical signatures - # such as - # - # async def fetch_result(self, ctx: FetchOperationResultContext, token: str) -> Output | asyncio.Awaitable[Output] - # - # Can we improve this DX? - - @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. - """ - - def start( - self, ctx: StartOperationContext, input: InputT - ) -> Union[ - StartOperationResultSync[OutputT], - Awaitable[StartOperationResultSync[OutputT]], - ]: - """ - Start the operation and return its final result synchronously. - - Note that this method may be either `async def` or `def`. The name - 'SyncOperationHandler' means that the operation responds synchronously according - to the Nexus protocol; it doesn't refer to whether or not the implementation of - the start method is an `async def` or `def`. - """ - raise NotImplementedError( - "Start method must be implemented by subclasses of SyncOperationHandler." - ) - - def fetch_info( - self, ctx: FetchOperationInfoContext, token: str - ) -> Union[OperationInfo, Awaitable[OperationInfo]]: - raise NotImplementedError( - "Cannot fetch operation info for an operation that responded synchronously." - ) - - def fetch_result( - self, ctx: FetchOperationResultContext, token: str - ) -> Union[OutputT, Awaitable[OutputT]]: - raise NotImplementedError( - "Cannot fetch the result of an operation that responded synchronously." - ) - - def cancel( - self, ctx: CancelOperationContext, token: str - ) -> Union[None, Awaitable[None]]: - raise NotImplementedError( - "An operation that responded synchronously cannot be cancelled." - ) - - -def collect_operation_handler_factories( - user_service_cls: Type[ServiceHandlerT], - service: Optional[nexusrpc.ServiceDefinition], -) -> dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]]: - """ - Collect operation handler methods from a user service handler class. - """ - factories = {} - op_defn_method_names = ( - { - op.method_name - for op in service.operations.values() - if op.method_name is not None - } - if service - else set() - ) - for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): - op_defn = getattr(method, "__nexus_operation__", None) - if isinstance(op_defn, nexusrpc.Operation): - # This is a method decorated with one of the *operation_handler decorators - # assert op_defn.name == name - if op_defn.name in factories: - 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 op_defn_method_names: - _names = ", ".join(f"'{s}'" for s in sorted(op_defn_method_names)) - raise TypeError( - 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: {_names}." - ) - - factories[op_defn.name] = method - # Check for accidentally missing decorator on an OperationHandler factory - # TODO(preview): support disabling warning in @service_handler decorator? - elif ( - typing.get_origin(typing.get_type_hints(method).get("return")) - == OperationHandler - ): - warnings.warn( - f"Method '{method}' in class '{user_service_cls}' " - f"returns OperationHandler but has not been decorated. " - f"Did you forget to apply the @nexusrpc.handler.operation_handler decorator?", - UserWarning, - stacklevel=2, - ) - return factories - - -def validate_operation_handler_methods( - user_service_cls: Type[ServiceHandlerT], - user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], - service_definition: nexusrpc.ServiceDefinition, -) -> None: - """Validate operation handler methods against a service definition.""" - for op_name, op_defn in service_definition.operations.items(): - method = user_methods.get(op_name) - if not method: - raise TypeError( - f"Service '{user_service_cls}' does not implement operation '{op_name}' in interface '{service_definition}'. " - ) - op = getattr(method, "__nexus_operation__", None) - if not isinstance(op, nexusrpc.Operation): - raise RuntimeError( - 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` or " - f":py:func:`@nexusrpc.handler.sync_operation_handler`?" - ) - # Input type is contravariant: op handler input must be superclass of op defn output - if ( - op.input_type is not None - and op_defn.input_type is not None - and Any not in (op.input_type, op_defn.input_type) - and not ( - op_defn.input_type == op.input_type - or issubclass(op_defn.input_type, op.input_type) - ) - ): - raise TypeError( - f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " - f"which is not compatible with the input type '{op_defn.input_type}' " - f" in interface '{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 ( - op.output_type is not None - and op_defn.output_type is not None - and Any not in (op.output_type, op_defn.output_type) - and not issubclass(op.output_type, op_defn.output_type) - ): - raise TypeError( - f"Operation '{op_name}' in service '{user_service_cls}' has output type '{op.output_type}', " - f"which is not compatible with the output type '{op_defn.output_type}' in interface '{service_definition}'. " - f"The output type must be the same as or a subclass of the operation definition output type." - ) - if service_definition.operations.keys() > user_methods.keys(): - raise TypeError( - f"Service '{user_service_cls}' does not implement all operations in interface '{service_definition}'. " - f"Missing operations: {service_definition.operations.keys() - user_methods.keys()}" - ) - if user_methods.keys() > service_definition.operations.keys(): - raise TypeError( - f"Service '{user_service_cls}' implements more operations than the interface '{service_definition}'. " - f"Extra operations: {user_methods.keys() - service_definition.operations.keys()}" - ) - - -def service_from_operation_handler_methods( - service_name: str, - user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], -) -> nexusrpc.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. - """ - operations: dict[str, nexusrpc.Operation[Any, Any]] = {} - for name, method in user_methods.items(): - op = getattr(method, "__nexus_operation__", None) - if not isinstance(op, nexusrpc.Operation): - raise RuntimeError( - 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` or " - f":py:func:`@nexusrpc.handler.sync_operation_handler`?" - ) - operations[op.name] = op - - return nexusrpc.ServiceDefinition(name=service_name, operations=operations) - - # TODO(prerelease): Do we want to require users to create this wrapper? Two # alternatives: # diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 475a61c..690eaf8 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -24,7 +24,7 @@ StartOperationContext, StartOperationResultSync, ) -from ._core import ( +from ._operation_handler import ( OperationHandler, SyncOperationHandler, collect_operation_handler_factories, diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py new file mode 100644 index 0000000..7bada86 --- /dev/null +++ b/src/nexusrpc/handler/_operation_handler.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import inspect +import typing +import warnings +from abc import ABC, abstractmethod +from typing import ( + Any, + Awaitable, + Callable, + Generic, + Optional, + Type, + Union, +) + +import nexusrpc +import nexusrpc._service +from nexusrpc.types import InputT, OutputT, ServiceHandlerT + +from ._common import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + OperationInfo, + 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. + + Alternatively, to create an operation handler that is limited to returning + synchronously, create the start method of the :py:class:`OperationHandler` on your + service handler class and apply the + :py:func:`@nexusrpc.handler.sync_operation_handler` decorator. + """ + + # TODO(preview): We are using `def` signatures with union return types in this abstract + # base class to represent both `def` and `async` def implementations in child classes. + # However, this causes VSCode to autocomplete the methods with non-sensical signatures + # such as + # + # async def fetch_result(self, ctx: FetchOperationResultContext, token: str) -> Output | asyncio.Awaitable[Output] + # + # Can we improve this DX? + + @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. + """ + + def start( + self, ctx: StartOperationContext, input: InputT + ) -> Union[ + StartOperationResultSync[OutputT], + Awaitable[StartOperationResultSync[OutputT]], + ]: + """ + Start the operation and return its final result synchronously. + + Note that this method may be either `async def` or `def`. The name + 'SyncOperationHandler' means that the operation responds synchronously according + to the Nexus protocol; it doesn't refer to whether or not the implementation of + the start method is an `async def` or `def`. + """ + raise NotImplementedError( + "Start method must be implemented by subclasses of SyncOperationHandler." + ) + + def fetch_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> Union[OperationInfo, Awaitable[OperationInfo]]: + raise NotImplementedError( + "Cannot fetch operation info for an operation that responded synchronously." + ) + + def fetch_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Union[OutputT, Awaitable[OutputT]]: + raise NotImplementedError( + "Cannot fetch the result of an operation that responded synchronously." + ) + + def cancel( + self, ctx: CancelOperationContext, token: str + ) -> Union[None, Awaitable[None]]: + raise NotImplementedError( + "An operation that responded synchronously cannot be cancelled." + ) + + +def collect_operation_handler_factories( + user_service_cls: Type[ServiceHandlerT], + service: Optional[nexusrpc.ServiceDefinition], +) -> dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]]: + """ + Collect operation handler methods from a user service handler class. + """ + factories = {} + op_defn_method_names = ( + { + op.method_name + for op in service.operations.values() + if op.method_name is not None + } + if service + else set() + ) + for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): + op_defn = getattr(method, "__nexus_operation__", None) + if isinstance(op_defn, nexusrpc.Operation): + # This is a method decorated with one of the *operation_handler decorators + # assert op_defn.name == name + if op_defn.name in factories: + 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 op_defn_method_names: + _names = ", ".join(f"'{s}'" for s in sorted(op_defn_method_names)) + raise TypeError( + 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: {_names}." + ) + + factories[op_defn.name] = method + # Check for accidentally missing decorator on an OperationHandler factory + # TODO(preview): support disabling warning in @service_handler decorator? + elif ( + typing.get_origin(typing.get_type_hints(method).get("return")) + == OperationHandler + ): + warnings.warn( + f"Method '{method}' in class '{user_service_cls}' " + f"returns OperationHandler but has not been decorated. " + f"Did you forget to apply the @nexusrpc.handler.operation_handler decorator?", + UserWarning, + stacklevel=2, + ) + return factories + + +def validate_operation_handler_methods( + user_service_cls: Type[ServiceHandlerT], + user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], + service_definition: nexusrpc.ServiceDefinition, +) -> None: + """Validate operation handler methods against a service definition.""" + for op_name, op_defn in service_definition.operations.items(): + method = user_methods.get(op_name) + if not method: + raise TypeError( + f"Service '{user_service_cls}' does not implement operation '{op_name}' in interface '{service_definition}'. " + ) + op = getattr(method, "__nexus_operation__", None) + if not isinstance(op, nexusrpc.Operation): + raise RuntimeError( + 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` or " + f":py:func:`@nexusrpc.handler.sync_operation_handler`?" + ) + # Input type is contravariant: op handler input must be superclass of op defn output + if ( + op.input_type is not None + and op_defn.input_type is not None + and Any not in (op.input_type, op_defn.input_type) + and not ( + op_defn.input_type == op.input_type + or issubclass(op_defn.input_type, op.input_type) + ) + ): + raise TypeError( + f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " + f"which is not compatible with the input type '{op_defn.input_type}' " + f" in interface '{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 ( + op.output_type is not None + and op_defn.output_type is not None + and Any not in (op.output_type, op_defn.output_type) + and not issubclass(op.output_type, op_defn.output_type) + ): + raise TypeError( + f"Operation '{op_name}' in service '{user_service_cls}' has output type '{op.output_type}', " + f"which is not compatible with the output type '{op_defn.output_type}' in interface '{service_definition}'. " + f"The output type must be the same as or a subclass of the operation definition output type." + ) + if service_definition.operations.keys() > user_methods.keys(): + raise TypeError( + f"Service '{user_service_cls}' does not implement all operations in interface '{service_definition}'. " + f"Missing operations: {service_definition.operations.keys() - user_methods.keys()}" + ) + if user_methods.keys() > service_definition.operations.keys(): + raise TypeError( + f"Service '{user_service_cls}' implements more operations than the interface '{service_definition}'. " + f"Extra operations: {user_methods.keys() - service_definition.operations.keys()}" + ) + + +def service_from_operation_handler_methods( + service_name: str, + user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], +) -> nexusrpc.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. + """ + operations: dict[str, nexusrpc.Operation[Any, Any]] = {} + for name, method in user_methods.items(): + op = getattr(method, "__nexus_operation__", None) + if not isinstance(op, nexusrpc.Operation): + raise RuntimeError( + 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` or " + f":py:func:`@nexusrpc.handler.sync_operation_handler`?" + ) + operations[op.name] = op + + return nexusrpc.ServiceDefinition(name=service_name, operations=operations) From 449f28af29327372f3d2cb4c0547993410440fcb Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 19 Jun 2025 14:21:41 -0400 Subject: [PATCH 055/178] Upgrade ruff --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 391d063..8310e9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dev = [ "pytest-asyncio>=0.26.0", "pytest-cov>=6.1.1", "pytest-pretty>=1.3.0", - "ruff>=0.11.7", + "ruff>=0.12.0", ] [build-system] diff --git a/uv.lock b/uv.lock index c5a8bab..eb9d2bf 100644 --- a/uv.lock +++ b/uv.lock @@ -537,7 +537,7 @@ dev = [ { 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.11.7" }, + { name = "ruff", specifier = ">=0.12.0" }, ] [[package]] @@ -706,27 +706,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.11.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/89/6f9c9674818ac2e9cc2f2b35b704b7768656e6b7c139064fc7ba8fbc99f1/ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4", size = 4054861, upload-time = "2025-04-24T18:49:37.007Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/ec/21927cb906c5614b786d1621dba405e3d44f6e473872e6df5d1a6bca0455/ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c", size = 10245403, upload-time = "2025-04-24T18:48:40.459Z" }, - { url = "https://files.pythonhosted.org/packages/e2/af/fec85b6c2c725bcb062a354dd7cbc1eed53c33ff3aa665165871c9c16ddf/ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee", size = 11007166, upload-time = "2025-04-24T18:48:44.742Z" }, - { url = "https://files.pythonhosted.org/packages/31/9a/2d0d260a58e81f388800343a45898fd8df73c608b8261c370058b675319a/ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada", size = 10378076, upload-time = "2025-04-24T18:48:47.918Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/9b09b45051404d2e7dd6d9dbcbabaa5ab0093f9febcae664876a77b9ad53/ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64", size = 10557138, upload-time = "2025-04-24T18:48:51.707Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5e/f62a1b6669870a591ed7db771c332fabb30f83c967f376b05e7c91bccd14/ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201", size = 10095726, upload-time = "2025-04-24T18:48:54.243Z" }, - { url = "https://files.pythonhosted.org/packages/45/59/a7aa8e716f4cbe07c3500a391e58c52caf665bb242bf8be42c62adef649c/ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6", size = 11672265, upload-time = "2025-04-24T18:48:57.639Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e3/101a8b707481f37aca5f0fcc3e42932fa38b51add87bfbd8e41ab14adb24/ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4", size = 12331418, upload-time = "2025-04-24T18:49:00.697Z" }, - { url = "https://files.pythonhosted.org/packages/dd/71/037f76cbe712f5cbc7b852e4916cd3cf32301a30351818d32ab71580d1c0/ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e", size = 11794506, upload-time = "2025-04-24T18:49:03.545Z" }, - { url = "https://files.pythonhosted.org/packages/ca/de/e450b6bab1fc60ef263ef8fcda077fb4977601184877dce1c59109356084/ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63", size = 13939084, upload-time = "2025-04-24T18:49:07.159Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2c/1e364cc92970075d7d04c69c928430b23e43a433f044474f57e425cbed37/ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502", size = 11450441, upload-time = "2025-04-24T18:49:11.41Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7d/1b048eb460517ff9accd78bca0fa6ae61df2b276010538e586f834f5e402/ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92", size = 10441060, upload-time = "2025-04-24T18:49:14.184Z" }, - { url = "https://files.pythonhosted.org/packages/3a/57/8dc6ccfd8380e5ca3d13ff7591e8ba46a3b330323515a4996b991b10bd5d/ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94", size = 10058689, upload-time = "2025-04-24T18:49:17.559Z" }, - { url = "https://files.pythonhosted.org/packages/23/bf/20487561ed72654147817885559ba2aa705272d8b5dee7654d3ef2dbf912/ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6", size = 11073703, upload-time = "2025-04-24T18:49:20.247Z" }, - { url = "https://files.pythonhosted.org/packages/9d/27/04f2db95f4ef73dccedd0c21daf9991cc3b7f29901a4362057b132075aa4/ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6", size = 11532822, upload-time = "2025-04-24T18:49:23.765Z" }, - { url = "https://files.pythonhosted.org/packages/e1/72/43b123e4db52144c8add336581de52185097545981ff6e9e58a21861c250/ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26", size = 10362436, upload-time = "2025-04-24T18:49:27.377Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a0/3e58cd76fdee53d5c8ce7a56d84540833f924ccdf2c7d657cb009e604d82/ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a", size = 11566676, upload-time = "2025-04-24T18:49:30.938Z" }, - { url = "https://files.pythonhosted.org/packages/68/ca/69d7c7752bce162d1516e5592b1cc6b6668e9328c0d270609ddbeeadd7cf/ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177", size = 10677936, upload-time = "2025-04-24T18:49:34.392Z" }, +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]] From bbd6bffea68d0cbf5618748471224fbff0f151e8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 19 Jun 2025 20:43:11 -0400 Subject: [PATCH 056/178] combine-as-imports formatter config --- pyproject.toml | 2 ++ src/nexusrpc/__init__.py | 16 ++++++++++------ src/nexusrpc/handler/__init__.py | 30 ------------------------------ 3 files changed, 12 insertions(+), 36 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8310e9b..a4eeb31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,3 +37,5 @@ include = ["src", "tests"] [tool.ruff] target-version = "py39" +[tool.ruff.lint.isort] +combine-as-imports = true diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 352f5f2..b741538 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -1,12 +1,16 @@ from dataclasses import dataclass from enum import Enum -from ._serializer import Content as Content -from ._serializer import LazyValueAsync as LazyValueAsync -from ._serializer import LazyValueSync as LazyValueSync -from ._service import Operation as Operation -from ._service import ServiceDefinition as ServiceDefinition -from ._service import service as service +from ._serializer import ( + Content as Content, + LazyValueAsync as LazyValueAsync, + LazyValueSync as LazyValueSync, +) +from ._service import ( + Operation as Operation, + ServiceDefinition as ServiceDefinition, + service as service, +) @dataclass diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 250381b..921f65a 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -12,59 +12,29 @@ from ._common import ( CancelOperationContext as CancelOperationContext, -) -from ._common import ( FetchOperationInfoContext as FetchOperationInfoContext, -) -from ._common import ( FetchOperationResultContext as FetchOperationResultContext, -) -from ._common import ( HandlerError as HandlerError, -) -from ._common import ( HandlerErrorType as HandlerErrorType, -) -from ._common import ( OperationContext as OperationContext, -) -from ._common import ( OperationError as OperationError, -) -from ._common import ( OperationErrorState as OperationErrorState, -) -from ._common import ( OperationInfo as OperationInfo, -) -from ._common import ( StartOperationContext as StartOperationContext, -) -from ._common import ( StartOperationResultAsync as StartOperationResultAsync, -) -from ._common import ( StartOperationResultSync as StartOperationResultSync, ) from ._core import ( HandlerAsync as HandlerAsync, -) -from ._core import ( HandlerSync as HandlerSync, ) from ._decorators import ( operation_handler as operation_handler, -) -from ._decorators import ( service_handler as service_handler, -) -from ._decorators import ( sync_operation_handler as sync_operation_handler, ) from ._operation_handler import ( OperationHandler as OperationHandler, -) -from ._operation_handler import ( SyncOperationHandler as SyncOperationHandler, ) from ._util import ( From 1e4d8fed7db706bedb826cb5f4fd6999afbd4dca Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 21 Jun 2025 09:26:51 -0400 Subject: [PATCH 057/178] Backport inspect.get_annotations from 3.13.5 --- src/nexusrpc/_util.py | 138 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 10 deletions(-) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index 7e4f40d..a266ddd 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -1,15 +1,133 @@ -from typing import Any - -# TODO(preview): backport inspect.get_annotations # See # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older -# https://github.com/shawwn/get-annotations/blob/main/get_annotations/__init__.py try: - from inspect import get_annotations as _get_annotations # type: ignore - - def get_annotations(obj: Any) -> dict[str, Any]: - return _get_annotations(obj) + 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): + """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__ + continue + if isinstance(unwrap, functools.partial): + unwrap = unwrap.func + continue + break + if hasattr(unwrap, "__globals__"): + obj_globals = unwrap.__globals__ + + 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 + - def get_annotations(obj: Any) -> dict[str, Any]: - return getattr(obj, "__annotations__", {}) +get_annotations = get_annotations From 996da25356b28868de2e77d3bc978edb74fd0af4 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 21 Jun 2025 10:20:18 -0400 Subject: [PATCH 058/178] Unskip test --- src/nexusrpc/_service.py | 2 +- tests/handler/test_forward_references.py | 9 +-------- .../test_service_definition_inheritance.py | 7 ++++++- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index b552ed8..c5c7e58 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -240,7 +240,7 @@ def _collect_operations( annotations = { k: v - for k, v in get_annotations(user_class).items() + for k, v in get_annotations(user_class, eval_str=True).items() if typing.get_origin(v) == Operation } for key in operations.keys() | annotations.keys(): diff --git a/tests/handler/test_forward_references.py b/tests/handler/test_forward_references.py index 5e574dc..ddf86d8 100644 --- a/tests/handler/test_forward_references.py +++ b/tests/handler/test_forward_references.py @@ -1,8 +1,5 @@ -# TODO(prerelease) This test fails with this import line from __future__ import annotations -import pytest - import nexusrpc import nexusrpc.handler @@ -12,11 +9,7 @@ class ContractA: base_op: nexusrpc.Operation[int, str] -@pytest.mark.skip( - reason="TODO(prerelease): The service contract decorator does not support forward type reference" -) def test_service_definition_decorator_collects_operations_from_annotations(): - user_service_defn_cls = nexusrpc.service(ContractA) - service_defn = getattr(user_service_defn_cls, "__nexus_service__", None) + service_defn = getattr(ContractA, "__nexus_service__", None) assert isinstance(service_defn, nexusrpc.ServiceDefinition) assert service_defn.operations.keys() == {"base_op"} diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 5745bc0..84cd9ef 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -1,3 +1,8 @@ +# 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 pprint import pprint from typing import Any, Optional, Type @@ -105,7 +110,7 @@ class A1: def test_user_service_definition_inheritance(test_case: Type[_TestCase]): print(f"\n\n{test_case.UserService.__name__}:") print("\n__annotations__") - pprint(get_annotations(test_case.UserService)) + pprint(get_annotations(test_case.UserService, eval_str=True)) print("\n__dict__") pprint(test_case.UserService.__dict__) From 7255c496391a509afbfa62b9115ae5bb6c777c9a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 21 Jun 2025 22:33:57 -0400 Subject: [PATCH 059/178] Docstring --- src/nexusrpc/handler/_common.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index fc1c30a..4c2c4b6 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -177,8 +177,11 @@ class StartOperationResultSync(Generic[OutputT]): @dataclass class StartOperationResultAsync: """ - A value returned by the start method of a nexus operation handler indicating that the - operation is responding asynchronously. + A value returned by the start method of a nexus operation handler indicating that + the operation is responding asynchronously. + + It contains a token that the caller can submit with subsequent ``fetch_info``, + ``fetch_result``, or ``cancel`` requests. """ token: str From 05fc6fe4b8aa3a466baa9e480bd117a8e3d0b6c9 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 22 Jun 2025 19:22:03 -0400 Subject: [PATCH 060/178] Cleanup --- src/nexusrpc/handler/_common.py | 1 + tests/handler/test_forward_references.py | 15 --------------- ...test_service_handler_decorator_requirements.py | 3 +-- .../test_service_definition_inheritance.py | 8 -------- 4 files changed, 2 insertions(+), 25 deletions(-) delete mode 100644 tests/handler/test_forward_references.py diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 4c2c4b6..a2d509d 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -184,4 +184,5 @@ class StartOperationResultAsync: ``fetch_result``, or ``cancel`` requests. """ + # TODO(prerelease): string or OperationToken Python object? token: str diff --git a/tests/handler/test_forward_references.py b/tests/handler/test_forward_references.py deleted file mode 100644 index ddf86d8..0000000 --- a/tests/handler/test_forward_references.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -import nexusrpc -import nexusrpc.handler - - -@nexusrpc.service -class ContractA: - base_op: nexusrpc.Operation[int, str] - - -def test_service_definition_decorator_collects_operations_from_annotations(): - service_defn = getattr(ContractA, "__nexus_service__", None) - assert isinstance(service_defn, nexusrpc.ServiceDefinition) - assert service_defn.operations.keys() == {"base_op"} diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 781b3b0..fb70ae1 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -1,5 +1,4 @@ -# TODO(prerelease): The service definition decorator does not support forward type reference -# from __future__ import annotations +from __future__ import annotations from typing import Any, Type diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 84cd9ef..8023005 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -3,13 +3,11 @@ # 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 pprint import pprint from typing import Any, Optional, Type import pytest from nexusrpc import Operation, ServiceDefinition, service -from nexusrpc._util import get_annotations # See https://docs.python.org/3/howto/annotations.html @@ -108,12 +106,6 @@ class A1: ], ) def test_user_service_definition_inheritance(test_case: Type[_TestCase]): - print(f"\n\n{test_case.UserService.__name__}:") - print("\n__annotations__") - pprint(get_annotations(test_case.UserService, eval_str=True)) - print("\n__dict__") - pprint(test_case.UserService.__dict__) - if test_case.expected_error: with pytest.raises(Exception, match=test_case.expected_error): service(test_case.UserService) From ba86bddf5aeab001865f29074f93bc42bb0b76d5 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 22 Jun 2025 21:54:52 -0400 Subject: [PATCH 061/178] Default to async --- src/nexusrpc/__init__.py | 6 +- src/nexusrpc/_serializer.py | 44 ++----- src/nexusrpc/handler/__init__.py | 4 +- src/nexusrpc/handler/_core.py | 118 +++++++++--------- tests/handler/test_handler_sync.py | 9 +- ...er_validates_service_handler_collection.py | 8 +- 6 files changed, 82 insertions(+), 107 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index b741538..b63dbe2 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -1,11 +1,7 @@ from dataclasses import dataclass from enum import Enum -from ._serializer import ( - Content as Content, - LazyValueAsync as LazyValueAsync, - LazyValueSync as LazyValueSync, -) +from ._serializer import Content as Content, LazyValue as LazyValue from ._service import ( Operation as Operation, ServiceDefinition as ServiceDefinition, diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index baaa5ac..38f87c7 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -1,6 +1,5 @@ from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass from typing import ( Any, @@ -39,8 +38,6 @@ class Serializer(Protocol): Serializer is used by the framework to serialize/deserialize input and output. """ - # TODO(preview): support non-async def - def serialize(self, value: Any) -> Union[Content, Awaitable[Content]]: """Serialize encodes a value into a Content.""" ... @@ -60,7 +57,7 @@ def deserialize( ... -class LazyValue(ABC): +class LazyValue: """ A container for a value encoded in an underlying stream. @@ -85,57 +82,42 @@ def __init__( self.headers = headers self.stream = stream - @abstractmethod - def consume( - self, as_type: Optional[Type[Any]] = None - ) -> Union[Any, Awaitable[Any]]: - """ - Consume the underlying reader stream, deserializing via the embedded serializer. - """ - ... - - -class LazyValueSync(LazyValue): - __doc__ = LazyValue.__doc__ - stream: Optional[Iterable[bytes]] - - def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: """ Consume the underlying reader stream, deserializing via the embedded serializer. """ # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? if self.stream is None: - return self.serializer.deserialize( + return await self.serializer.deserialize( Content(headers=self.headers), as_type=as_type ) + elif not isinstance(self.stream, AsyncIterable): + raise ValueError("When using consume, stream must be an AsyncIterable") - return self.serializer.deserialize( + return await self.serializer.deserialize( Content( headers=self.headers, - data=b"".join([c for c in self.stream]), + data=b"".join([c async for c in self.stream]), ), as_type=as_type, ) - -class LazyValueAsync(LazyValue): - __doc__ = LazyValue.__doc__ - stream: Optional[AsyncIterable[bytes]] - - async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: + def consume_sync(self, as_type: Optional[Type[Any]] = None) -> Any: """ Consume the underlying reader stream, deserializing via the embedded serializer. """ # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? if self.stream is None: - return await self.serializer.deserialize( + return self.serializer.deserialize( Content(headers=self.headers), as_type=as_type ) + elif not isinstance(self.stream, Iterable): + raise ValueError("When using consume_sync, stream must be an Iterable") - return await self.serializer.deserialize( + return self.serializer.deserialize( Content( headers=self.headers, - data=b"".join([c async for c in self.stream]), + data=b"".join([c for c in self.stream]), ), as_type=as_type, ) diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 921f65a..e470c40 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -25,8 +25,8 @@ StartOperationResultSync as StartOperationResultSync, ) from ._core import ( - HandlerAsync as HandlerAsync, - HandlerSync as HandlerSync, + Handler as Handler, + SyncioHandler as SyncioHandler, ) from ._decorators import ( operation_handler as operation_handler, diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index d538dcd..0b4a165 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -20,7 +20,7 @@ import nexusrpc._service from nexusrpc.handler._util import is_async_callable -from .._serializer import LazyValue, LazyValueAsync, LazyValueSync +from .._serializer import LazyValue from ._common import ( CancelOperationContext, FetchOperationInfoContext, @@ -154,20 +154,18 @@ def _get_service_handler(self, service_name: str) -> ServiceHandler: return service -class HandlerSync(BaseHandler): +class Handler(BaseHandler): """ - A Nexus handler with non-async `def` methods. - A Nexus handler manages 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 start_operation( + async def start_operation( self, ctx: StartOperationContext, - input: LazyValueSync, + input: LazyValue, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, @@ -181,22 +179,29 @@ def start_operation( 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) + deserialized_input = await input.consume(as_type=op.input_type) if is_async_callable(op_handler.start): - raise RuntimeError( - "Operation start handler method is an `async def` and " - "cannot be called from a sync handler. " - ) - # TODO(preview): apply middleware stack as composed functions - if not self.executor: - raise RuntimeError( - "Operation start handler method is not an `async def` but " - "no executor was provided to the Handler constructor. " + # TODO(preview): apply middleware stack as composed awaitables + return await op_handler.start(ctx, deserialized_input) + else: + # TODO(preview): apply middleware stack as composed functions + if not self.executor: + raise RuntimeError( + "Operation start handler method is not an `async def` but " + "no executor was provided to the Handler constructor. " + ) + result = await self.executor.submit_to_event_loop( + op_handler.start, ctx, deserialized_input ) - return self.executor.submit(op_handler.start, ctx, deserialized_input).result() + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation start handler method {op_handler.start} returned an " + "awaitable but is not an `async def` coroutine function." + ) + return result - def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: + async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: """Handle a Cancel Operation request. Args: @@ -206,32 +211,37 @@ def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: service_handler = self._get_service_handler(ctx.service) op_handler = service_handler._get_operation_handler(ctx.operation) if is_async_callable(op_handler.cancel): - raise RuntimeError( - "Operation cancel handler method is an `async def` and " - "cannot be called from a sync handler. " - ) + return await op_handler.cancel(ctx, token) else: if not self.executor: raise RuntimeError( "Operation cancel handler method is not an `async def` function but " "no executor was provided to the Handler constructor." ) - return self.executor.submit(op_handler.cancel, ctx, token).result() + result = await self.executor.submit_to_event_loop( + op_handler.cancel, ctx, token + ) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation cancel handler method {op_handler.cancel} returned an " + "awaitable but is not an `async def` function." + ) + return result - def fetch_operation_info( + async def fetch_operation_info( self, ctx: FetchOperationInfoContext, token: str ) -> OperationInfo: raise NotImplementedError - def fetch_operation_result( + async def fetch_operation_result( self, ctx: FetchOperationResultContext, token: str ) -> Any: raise NotImplementedError -class HandlerAsync(BaseHandler): +class SyncioHandler(BaseHandler): """ - A Nexus handler with `async def` methods. + A Nexus handler with non-async `def` methods. A Nexus handler manages a collection of Nexus service handlers. @@ -239,10 +249,10 @@ class HandlerAsync(BaseHandler): name in the operation context. """ - async def start_operation( + def start_operation( self, ctx: StartOperationContext, - input: LazyValueAsync, + input: LazyValue, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, @@ -256,29 +266,22 @@ async def start_operation( 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) + deserialized_input = input.consume_sync(as_type=op.input_type) if is_async_callable(op_handler.start): - # TODO(preview): apply middleware stack as composed awaitables - return await op_handler.start(ctx, deserialized_input) - else: - # TODO(preview): apply middleware stack as composed functions - if not self.executor: - raise RuntimeError( - "Operation start handler method is not an `async def` but " - "no executor was provided to the Handler constructor. " - ) - result = await self.executor.submit_to_event_loop( - op_handler.start, ctx, deserialized_input + raise RuntimeError( + "Operation start handler method is an `async def` and " + "cannot be called from a sync handler. " ) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation start handler method {op_handler.start} returned an " - "awaitable but is not an `async def` coroutine function." - ) - return result + # TODO(preview): apply middleware stack as composed functions + if not self.executor: + raise RuntimeError( + "Operation start handler method is not an `async def` but " + "no executor was provided to the Handler constructor. " + ) + return self.executor.submit(op_handler.start, ctx, deserialized_input).result() - async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: + def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: """Handle a Cancel Operation request. Args: @@ -288,29 +291,24 @@ async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> Non 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) + raise RuntimeError( + "Operation cancel handler method is an `async def` and " + "cannot be called from a sync handler. " + ) else: if not self.executor: raise RuntimeError( "Operation cancel handler method is not an `async def` function but " "no executor was provided to the Handler constructor." ) - result = await self.executor.submit_to_event_loop( - op_handler.cancel, ctx, token - ) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation cancel handler method {op_handler.cancel} returned an " - "awaitable but is not an `async def` function." - ) - return result + return self.executor.submit(op_handler.cancel, ctx, token).result() - async def fetch_operation_info( + def fetch_operation_info( self, ctx: FetchOperationInfoContext, token: str ) -> OperationInfo: raise NotImplementedError - async def fetch_operation_result( + def fetch_operation_result( self, ctx: FetchOperationResultContext, token: str ) -> Any: raise NotImplementedError diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index e5c23b9..7739967 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -4,15 +4,14 @@ import pytest -from nexusrpc import LazyValueSync -from nexusrpc._serializer import Content +from nexusrpc._serializer import Content, LazyValue from nexusrpc.handler import ( StartOperationContext, + SyncioHandler, service_handler, ) from nexusrpc.handler._common import StartOperationResultSync from nexusrpc.handler._decorators import sync_operation_handler -from nexusrpc.handler import HandlerSync class _TestCase: @@ -31,7 +30,7 @@ def incr(self, ctx: StartOperationContext, input: int) -> int: @pytest.mark.parametrize("test_case", [SyncHandlerHappyPath]) def test_sync_handler_happy_path(test_case: Type[_TestCase]): - handler = HandlerSync( + handler = SyncioHandler( user_service_handlers=[test_case.user_service_handler], executor=ThreadPoolExecutor(max_workers=1), ) @@ -39,7 +38,7 @@ def test_sync_handler_happy_path(test_case: Type[_TestCase]): service="MyService", operation="incr", ) - result = handler.start_operation(ctx, LazyValueSync(DummySerializer(1), headers={})) + result = handler.start_operation(ctx, LazyValue(DummySerializer(1), headers={})) assert isinstance(result, StartOperationResultSync) assert result.value == 2 diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index 35d4cdd..7863ca2 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -6,7 +6,7 @@ import pytest import nexusrpc.handler -from nexusrpc.handler import HandlerAsync +from nexusrpc.handler import Handler def test_service_must_use_decorator(): @@ -14,7 +14,7 @@ class Service: pass with pytest.raises(RuntimeError): - HandlerAsync([Service()]) + Handler([Service()]) def test_services_are_collected(): @@ -37,7 +37,7 @@ class Service1: def op(self) -> nexusrpc.handler.OperationHandler[int, int]: return OpHandler() - service_handlers = HandlerAsync([Service1()]) + 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() == { @@ -55,4 +55,4 @@ class Service2: pass with pytest.raises(RuntimeError): - HandlerAsync([Service1(), Service2()]) + Handler([Service1(), Service2()]) From 96e16187db1a82889e651c2c5238498f4096dd66 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 22 Jun 2025 22:52:55 -0400 Subject: [PATCH 062/178] Refactor types --- src/nexusrpc/handler/_decorators.py | 53 ++++++++++------------------- 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 690eaf8..ab40cb0 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -214,52 +214,38 @@ def decorator( return decorator(method) +OperationHandlerStartMethodT = Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], +] + +OperationHandlerFactoryT = Callable[ + [ServiceHandlerT], OperationHandler[InputT, OutputT] +] + + @overload def sync_operation_handler( - start_method: Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ], -) -> Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]]: ... + start_method: OperationHandlerStartMethodT, +) -> OperationHandlerFactoryT: ... @overload def sync_operation_handler( *, name: Optional[str] = None, -) -> Callable[ - [ - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ] - ], - Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]], -]: ... +) -> Callable[[OperationHandlerStartMethodT], OperationHandlerFactoryT]: ... # TODO(preview): how do we help users that accidentally use @sync_operation_handler on a function that # returns nexusrpc.handler.Operation[Input, Output]? def sync_operation_handler( - start_method: Optional[ - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ] - ] = None, + start_method: Optional[OperationHandlerStartMethodT] = None, *, name: Optional[str] = None, ) -> Union[ - Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]], - Callable[ - [ - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ] - ], - Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]], - ], + OperationHandlerFactoryT, + Callable[[OperationHandlerStartMethodT], OperationHandlerFactoryT], ]: """Decorator marking a start method as a synchronous operation handler. @@ -279,11 +265,8 @@ def my_operation(self, ctx: StartOperationContext, input: InputT) -> OutputT: """ def decorator( - start_method: Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ], - ) -> Callable[[ServiceHandlerT], OperationHandler[InputT, OutputT]]: + start_method: OperationHandlerStartMethodT, + ) -> OperationHandlerFactoryT: def factory(service: ServiceHandlerT) -> OperationHandler[InputT, OutputT]: op = SyncOperationHandler[InputT, OutputT]() From af994784adb056f968e862b7098720da88d3689e Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 22 Jun 2025 23:42:49 -0400 Subject: [PATCH 063/178] Switch to SyncOperationHandler --- src/nexusrpc/handler/__init__.py | 1 - src/nexusrpc/handler/_core.py | 6 +- src/nexusrpc/handler/_decorators.py | 155 +---------------- src/nexusrpc/handler/_operation_handler.py | 61 ++++--- src/nexusrpc/handler/syncio.py | 76 +++++++++ tests/handler/test_handler_sync.py | 15 +- ...er_validates_service_handler_collection.py | 14 +- ...collects_expected_operation_definitions.py | 78 +++++---- ...rrectly_functioning_operation_factories.py | 33 +++- ...ator_validates_against_service_contract.py | 160 ++++++++++++------ ...tor_validates_duplicate_operation_names.py | 6 +- ...test_service_handler_from_user_instance.py | 37 ++-- ...corator_creates_valid_operation_handler.py | 40 +++-- 13 files changed, 364 insertions(+), 318 deletions(-) create mode 100644 src/nexusrpc/handler/syncio.py diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index e470c40..e1e0660 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -31,7 +31,6 @@ from ._decorators import ( operation_handler as operation_handler, service_handler as service_handler, - sync_operation_handler as sync_operation_handler, ) from ._operation_handler import ( OperationHandler as OperationHandler, diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 0b4a165..7889e1f 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -239,6 +239,9 @@ async def fetch_operation_result( raise NotImplementedError +# TODO(prerelease): we have a syncio module now housing the syncio version of +# SyncOperationHandler. If we're retaining that then this (and an async version of +# LazyValue) should go in there. class SyncioHandler(BaseHandler): """ A Nexus handler with non-async `def` methods. @@ -320,8 +323,7 @@ class ServiceHandler: 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` or - :py:func:`@nexusrpc.handler.sync_operation_handler`. + using decorators such as :py:func:`@nexusrpc.handler.operation_handler`. Instances of this class are created automatically from user service handler instances on creation of a Handler instance, at Nexus handler start time. While the user's class diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index ab40cb0..29769fd 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -1,40 +1,26 @@ from __future__ import annotations -import types import typing import warnings -from functools import wraps from typing import ( Any, - Awaitable, Callable, Optional, Type, TypeVar, Union, - cast, overload, ) import nexusrpc -from nexusrpc.types import InputT, OutputT, ServiceHandlerT +from nexusrpc.types import ServiceHandlerT -from ._common import ( - CancelOperationContext, - StartOperationContext, - StartOperationResultSync, -) from ._operation_handler import ( OperationHandler, - SyncOperationHandler, collect_operation_handler_factories, service_from_operation_handler_methods, validate_operation_handler_methods, ) -from ._util import ( - get_start_method_input_and_output_types_annotations, - is_async_callable, -) @overload @@ -69,8 +55,7 @@ def service_handler( 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` - or :py:func:`@nexusrpc.handler.sync_operation_handler`. + operation handler decorators such as :py:func:`@nexusrpc.handler.operation_handler`. Args: cls: The service handler class to decorate. @@ -212,139 +197,3 @@ def decorator( return decorator return decorator(method) - - -OperationHandlerStartMethodT = Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], -] - -OperationHandlerFactoryT = Callable[ - [ServiceHandlerT], OperationHandler[InputT, OutputT] -] - - -@overload -def sync_operation_handler( - start_method: OperationHandlerStartMethodT, -) -> OperationHandlerFactoryT: ... - - -@overload -def sync_operation_handler( - *, - name: Optional[str] = None, -) -> Callable[[OperationHandlerStartMethodT], OperationHandlerFactoryT]: ... - - -# TODO(preview): how do we help users that accidentally use @sync_operation_handler on a function that -# returns nexusrpc.handler.Operation[Input, Output]? -def sync_operation_handler( - start_method: Optional[OperationHandlerStartMethodT] = None, - *, - name: Optional[str] = None, -) -> Union[ - OperationHandlerFactoryT, - Callable[[OperationHandlerStartMethodT], OperationHandlerFactoryT], -]: - """Decorator marking a start method as a synchronous operation handler. - - Apply this decorator to a start method to convert it into an operation handler - factory method. - - Args: - start_method: The start method to decorate. - name: Optional name for the operation. If not provided, the method name will be used. - - Examples: - .. code-block:: python - - @nexusrpc.handler.sync_operation_handler - def my_operation(self, ctx: StartOperationContext, input: InputT) -> OutputT: - ... - """ - - def decorator( - start_method: OperationHandlerStartMethodT, - ) -> OperationHandlerFactoryT: - def factory(service: ServiceHandlerT) -> OperationHandler[InputT, OutputT]: - op = SyncOperationHandler[InputT, OutputT]() - - # Non-async functions returning Awaitable are not supported - if is_async_callable(start_method): - start_method_async = cast( - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Awaitable[OutputT], - ], - start_method, - ) - - @wraps(start_method) - async def start_async( - _, ctx: StartOperationContext, input: InputT - ) -> StartOperationResultSync[OutputT]: - result = await start_method_async(service, ctx, input) - return StartOperationResultSync(result) - - op.start = types.MethodType(start_async, op) - - async def cancel_async(_, ctx: CancelOperationContext, token: str): - raise NotImplementedError( - "An operation that responded synchronously cannot be cancelled." - ) - - op.cancel = types.MethodType(cancel_async, op) - - else: - start_method_sync = cast( - Callable[[ServiceHandlerT, StartOperationContext, InputT], OutputT], - start_method, - ) - - @wraps(start_method) - def start( - _, ctx: StartOperationContext, input: InputT - ) -> StartOperationResultSync[OutputT]: - result = start_method_sync(service, ctx, input) - return StartOperationResultSync(result) - - op.start = types.MethodType(start, op) - - def cancel(_, ctx: CancelOperationContext, token: str): - raise NotImplementedError( - "An operation that responded synchronously cannot be cancelled." - ) - - op.cancel = types.MethodType(cancel, op) - return op - - input_type, output_type = get_start_method_input_and_output_types_annotations( - start_method - ) - method_name = getattr(start_method, "__name__", None) - if ( - not method_name - and callable(start_method) - and hasattr(start_method, "__call__") - ): - method_name = start_method.__class__.__name__ - if not method_name: - raise TypeError( - f"Could not determine operation method name: " - f"expected {start_method} to be a function or callable instance." - ) - - factory.__nexus_operation__ = nexusrpc.Operation( - name=name or method_name, - method_name=method_name, - input_type=input_type, - output_type=output_type, - ) - - return factory - - if start_method is None: - return decorator - - return decorator(start_method) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 7bada86..66b5bcd 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -16,6 +16,7 @@ import nexusrpc import nexusrpc._service +from nexusrpc.handler._util import is_async_callable from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._common import ( @@ -37,10 +38,9 @@ class OperationHandler(ABC, Generic[InputT, OutputT]): that takes `self` and returns an instance of :py:class:`OperationHandler`, and apply the :py:func:`@nexusrpc.handler.operation_handler` decorator. - Alternatively, to create an operation handler that is limited to returning - synchronously, create the start method of the :py:class:`OperationHandler` on your - service handler class and apply the - :py:func:`@nexusrpc.handler.sync_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. """ # TODO(preview): We are using `def` signatures with union return types in this abstract @@ -97,44 +97,61 @@ def cancel( ... +# TODO(prerelease): I'm worried that it will be confusing to users that they can't +# subclass this class and override the start method (currently, they would have to use +# SyncOperationHandler for that). 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 start( + def __init__( + self, + start_method: Callable[[StartOperationContext, InputT], Awaitable[OutputT]], + ): + if not is_async_callable(start_method): + raise RuntimeError( + f"{start_method} 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_method = start_method + if start_method.__doc__: + self.start.__func__.__doc__ = start_method.__doc__ + + async def start( self, ctx: StartOperationContext, input: InputT - ) -> Union[ - StartOperationResultSync[OutputT], - Awaitable[StartOperationResultSync[OutputT]], - ]: + ) -> StartOperationResultSync[OutputT]: """ Start the operation and return its final result synchronously. - Note that this method may be either `async def` or `def`. The name - 'SyncOperationHandler' means that the operation responds synchronously according - to the Nexus protocol; it doesn't refer to whether or not the implementation of - the start method is an `async def` or `def`. + 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`. """ - raise NotImplementedError( - "Start method must be implemented by subclasses of SyncOperationHandler." - ) + output = await self.start_method(ctx, input) + return StartOperationResultSync(output) - def fetch_info( + async def fetch_info( self, ctx: FetchOperationInfoContext, token: str ) -> Union[OperationInfo, Awaitable[OperationInfo]]: raise NotImplementedError( "Cannot fetch operation info for an operation that responded synchronously." ) - def fetch_result( + async def fetch_result( self, ctx: FetchOperationResultContext, token: str ) -> Union[OutputT, Awaitable[OutputT]]: raise NotImplementedError( "Cannot fetch the result of an operation that responded synchronously." ) - def cancel( + async def cancel( self, ctx: CancelOperationContext, token: str ) -> Union[None, Awaitable[None]]: raise NotImplementedError( @@ -212,8 +229,7 @@ def validate_operation_handler_methods( 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` or " - f":py:func:`@nexusrpc.handler.sync_operation_handler`?" + f":py:func:`@nexusrpc.handler.operation_handler`?" ) # Input type is contravariant: op handler input must be superclass of op defn output if ( @@ -275,8 +291,7 @@ def service_from_operation_handler_methods( 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` or " - f":py:func:`@nexusrpc.handler.sync_operation_handler`?" + f":py:func:`@nexusrpc.handler.operation_handler`?" ) operations[op.name] = op diff --git a/src/nexusrpc/handler/syncio.py b/src/nexusrpc/handler/syncio.py new file mode 100644 index 0000000..c28144f --- /dev/null +++ b/src/nexusrpc/handler/syncio.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import ( + Awaitable, + Callable, + Union, +) + +from nexusrpc.handler import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + OperationHandler, + OperationInfo, + StartOperationContext, + StartOperationResultSync, +) +from nexusrpc.handler._util import is_async_callable +from nexusrpc.types import InputT, OutputT + + +class SyncOperationHandler(OperationHandler[InputT, OutputT]): + """ + An :py:class:`OperationHandler` that is limited to responding synchronously. + + This version of the class uses traditional `def` methods, instead of `async def`. + For the async version, see :py:class:`nexusrpc.handler.SyncOperationHandler`. + """ + + def __init__( + self, + start_method: Callable[[StartOperationContext, InputT], OutputT], + ): + if is_async_callable(start_method): + raise RuntimeError( + f"{start_method} is an `async def` method. " + "syncio.SyncOperationHandler must be initialized with a `def` method. " + "To use `async def` methods, see :py:class:`nexusrpc.handler.SyncOperationHandler`." + ) + self.start_method = start_method + if start_method.__doc__: + self.start.__func__.__doc__ = start_method.__doc__ + + def start( + self, ctx: StartOperationContext, input: InputT + ) -> StartOperationResultSync[OutputT]: + """ + 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`. + """ + output = self.start_method(ctx, input) + return StartOperationResultSync(output) + + def fetch_info( + self, ctx: FetchOperationInfoContext, token: str + ) -> Union[OperationInfo, Awaitable[OperationInfo]]: + raise NotImplementedError( + "Cannot fetch operation info for an operation that responded synchronously." + ) + + def fetch_result( + self, ctx: FetchOperationResultContext, token: str + ) -> Union[OutputT, Awaitable[OutputT]]: + raise NotImplementedError( + "Cannot fetch the result of an operation that responded synchronously." + ) + + def cancel( + self, ctx: CancelOperationContext, token: str + ) -> Union[None, Awaitable[None]]: + raise NotImplementedError( + "An operation that responded synchronously cannot be cancelled." + ) diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index 7739967..9bece1e 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -6,12 +6,14 @@ from nexusrpc._serializer import Content, LazyValue from nexusrpc.handler import ( + OperationHandler, StartOperationContext, + StartOperationResultSync, SyncioHandler, + operation_handler, service_handler, ) -from nexusrpc.handler._common import StartOperationResultSync -from nexusrpc.handler._decorators import sync_operation_handler +from nexusrpc.handler.syncio import SyncOperationHandler class _TestCase: @@ -21,9 +23,12 @@ class _TestCase: class SyncHandlerHappyPath: @service_handler class MyService: - @sync_operation_handler - def incr(self, ctx: StartOperationContext, input: int) -> int: - return input + 1 + @operation_handler + def incr(self) -> OperationHandler[int, int]: + def start(ctx: StartOperationContext, input: int) -> int: + return input + 1 + + return SyncOperationHandler(start) user_service_handler = MyService() diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index 7863ca2..b2d7023 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -18,7 +18,7 @@ class Service: def test_services_are_collected(): - class OpHandler(nexusrpc.handler.SyncOperationHandler[int, int]): + class OpHandler(nexusrpc.handler.OperationHandler[int, int]): async def start( self, ctx: nexusrpc.handler.StartOperationContext, @@ -31,6 +31,18 @@ async def cancel( token: str, ) -> None: ... + async def fetch_info( + self, + ctx: nexusrpc.handler.FetchOperationInfoContext, + token: str, + ) -> nexusrpc.handler.OperationInfo: ... + + async def fetch_result( + self, + ctx: nexusrpc.handler.FetchOperationResultContext, + token: str, + ) -> int: ... + @nexusrpc.handler.service_handler class Service1: @nexusrpc.handler.operation_handler 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 index 3d1ed66..d8c6fa0 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -27,6 +27,7 @@ class _TestCase: Service: Type[Any] expected_operations: dict[str, nexusrpc.Operation] Contract: Optional[Type[nexusrpc.ServiceDefinition]] = None + skip: Optional[str] = None class ManualOperationHandler(_TestCase): @@ -64,10 +65,10 @@ def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... class SyncOperation(_TestCase): @nexusrpc.handler.service_handler class Service: - @nexusrpc.handler.sync_operation_handler + @nexusrpc.handler.operation_handler def sync_operation_handler( - self, ctx: nexusrpc.handler.StartOperationContext, input: Input - ) -> Output: ... + self, + ) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { "sync_operation_handler": nexusrpc.Operation( @@ -82,10 +83,10 @@ def sync_operation_handler( class SyncOperationWithOperationHandlerNameOverride(_TestCase): @nexusrpc.handler.service_handler class Service: - @nexusrpc.handler.sync_operation_handler(name="sync-operation-name") - async def sync_operation_handler( - self, ctx: nexusrpc.handler.StartOperationContext, input: Input - ) -> Output: ... + @nexusrpc.handler.operation_handler(name="sync-operation-name") + def sync_operation_handler( + self, + ) -> nexusrpc.handler.OperationHandler[Input, Output]: ... expected_operations = { "sync_operation_handler": nexusrpc.Operation( @@ -139,36 +140,40 @@ def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... } -class SyncOperationWithCallableInstance(_TestCase): - @nexusrpc.service - class Contract: - sync_operation_with_callable_instance: nexusrpc.Operation[Input, Output] +if False: - @nexusrpc.handler.service_handler(service=Contract) - class Service: - class sync_operation_with_callable_instance: - def __call__( - self, - _handler: Any, - ctx: nexusrpc.handler.StartOperationContext, - input: Input, - ) -> Output: ... - - # TODO(preview): improve the DX here. The decorator cannot be placed on the - # callable class itself, because the user must be responsible for instantiating - # the class to obtain the callable instance. - sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( # type: ignore - sync_operation_with_callable_instance() - ) + class SyncOperationWithCallableInstance(_TestCase): + skip = "TODO(prerelease): update this test after decorator change" - expected_operations = { - "sync_operation_with_callable_instance": nexusrpc.Operation( - name="sync_operation_with_callable_instance", - method_name="CallableInstanceStartMethod", - input_type=Input, - output_type=Output, - ), - } + @nexusrpc.service + class Contract: + sync_operation_with_callable_instance: nexusrpc.Operation[Input, Output] + + @nexusrpc.handler.service_handler(service=Contract) + class Service: + class sync_operation_with_callable_instance: + def __call__( + self, + _handler: Any, + ctx: nexusrpc.handler.StartOperationContext, + input: Input, + ) -> Output: ... + + # TODO(preview): improve the DX here. The decorator cannot be placed on the + # callable class itself, because the user must be responsible for instantiating + # the class to obtain the callable instance. + sync_operation_with_callable_instance = nexusrpc.handler.operation_handler( + sync_operation_with_callable_instance() # type: ignore + ) + + 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( @@ -190,6 +195,9 @@ def __call__( async def test_collected_operation_definitions( test_case: Type[_TestCase], ): + if test_case.skip: + pytest.skip(test_case.skip) + service: nexusrpc.ServiceDefinition = getattr( test_case.Service, "__nexus_service__" ) 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 index 12aa50c..763fd61 100644 --- 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 @@ -9,9 +9,10 @@ import nexusrpc._service import nexusrpc.handler -from nexusrpc.types import InputT, OutputT from nexusrpc.handler._core import collect_operation_handler_factories +from nexusrpc.handler._operation_handler import SyncOperationHandler from nexusrpc.handler._util import is_async_callable +from nexusrpc.types import InputT, OutputT @dataclass @@ -25,12 +26,27 @@ class ManualOperationDefinition(_TestCase): class Service: @nexusrpc.handler.operation_handler def operation(self) -> nexusrpc.handler.OperationHandler[int, int]: - class OpHandler(nexusrpc.handler.SyncOperationHandler[int, int]): + class OpHandler(nexusrpc.handler.OperationHandler[int, int]): async def start( self, ctx: nexusrpc.handler.StartOperationContext, input: int ) -> nexusrpc.handler.StartOperationResultSync[int]: return nexusrpc.handler.StartOperationResultSync(7) + def fetch_info( + self, ctx: nexusrpc.handler.FetchOperationInfoContext, token: str + ) -> nexusrpc.handler.OperationInfo: + raise NotImplementedError + + def fetch_result( + self, ctx: nexusrpc.handler.FetchOperationResultContext, token: str + ) -> int: + raise NotImplementedError + + def cancel( + self, ctx: nexusrpc.handler.CancelOperationContext, token: str + ) -> None: + raise NotImplementedError + return OpHandler() expected_operation_factories = {"operation": 7} @@ -39,11 +55,14 @@ async def start( class SyncOperation(_TestCase): @nexusrpc.handler.service_handler class Service: - @nexusrpc.handler.sync_operation_handler - def sync_operation_handler( - self, ctx: nexusrpc.handler.StartOperationContext, input: int - ) -> int: - return 7 + @nexusrpc.handler.operation_handler + def sync_operation_handler(self) -> nexusrpc.handler.OperationHandler[int, int]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: int + ) -> int: + return 7 + + return SyncOperationHandler(start) expected_operation_factories = {"sync_operation_handler": 7} 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 index bbbcd74..10a2e0e 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -4,6 +4,7 @@ import nexusrpc import nexusrpc.handler +from nexusrpc.handler._operation_handler import SyncOperationHandler class _InterfaceImplementationTestCase: @@ -20,10 +21,13 @@ class Interface: def unrelated_method(self) -> None: ... class Impl: - @nexusrpc.handler.sync_operation_handler - async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: None - ) -> None: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[None, None]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: None + ) -> None: ... + + return SyncOperationHandler(start) error_message = None @@ -34,10 +38,13 @@ class Interface: pass class Impl: - @nexusrpc.handler.sync_operation_handler - async def extra_op( - self, ctx: nexusrpc.handler.StartOperationContext, input: None - ) -> None: ... + @nexusrpc.handler.operation_handler + def extra_op(self) -> nexusrpc.handler.OperationHandler[None, None]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: None + ) -> None: ... + + return SyncOperationHandler(start) def unrelated_method(self) -> None: ... @@ -50,8 +57,11 @@ class Interface: op: nexusrpc.Operation[int, str] class Impl: - @nexusrpc.handler.sync_operation_handler - async def op(self, ctx, input): ... + @nexusrpc.handler.operation_handler + def op(self): + async def start(ctx, input): ... + + return SyncOperationHandler(start) error_message = None @@ -73,10 +83,13 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @nexusrpc.handler.sync_operation_handler - async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input - ) -> None: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[None, None]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input + ) -> None: ... + + return SyncOperationHandler(start) error_message = None @@ -87,10 +100,15 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @nexusrpc.handler.sync_operation_handler - async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: str - ) -> None: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[str, None]: + async def start( + # TODO(prerelease) isn't this supposed to be missing the ctx annotation? + ctx: nexusrpc.handler.StartOperationContext, + input: str, + ) -> None: ... + + return SyncOperationHandler(start) error_message = "is not compatible with the input type" @@ -101,10 +119,13 @@ class Interface: op: nexusrpc.Operation[None, int] class Impl: - @nexusrpc.handler.sync_operation_handler - async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: None - ) -> str: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[None, str]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: None + ) -> str: ... + + return SyncOperationHandler(start) error_message = "is not compatible with the output type" @@ -115,10 +136,13 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @nexusrpc.handler.sync_operation_handler - async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: str - ) -> str: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[str, str]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: str + ) -> str: ... + + return SyncOperationHandler(start) error_message = "is not compatible with the output type" @@ -129,10 +153,13 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @nexusrpc.handler.sync_operation_handler - async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: str - ) -> None: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[str, None]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: str + ) -> None: ... + + return SyncOperationHandler(start) error_message = None @@ -143,10 +170,13 @@ class Interface: op: nexusrpc.Operation[Any, Any] class Impl: - @nexusrpc.handler.sync_operation_handler - async def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: str - ) -> str: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[str, str]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: str + ) -> str: ... + + return SyncOperationHandler(start) error_message = None @@ -169,8 +199,13 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @nexusrpc.handler.sync_operation_handler - def op(self, ctx: nexusrpc.handler.StartOperationContext, input: X) -> X: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[X, X]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: X + ) -> X: ... + + return SyncOperationHandler(start) error_message = None @@ -181,10 +216,13 @@ class Interface: op: nexusrpc.Operation[X, SuperClass] class Impl: - @nexusrpc.handler.sync_operation_handler - def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: X - ) -> Subclass: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[X, Subclass]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: X + ) -> Subclass: ... + + return SyncOperationHandler(start) error_message = None @@ -197,10 +235,13 @@ class Interface: op: nexusrpc.Operation[X, Subclass] class Impl: - @nexusrpc.handler.sync_operation_handler - def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: X - ) -> SuperClass: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[X, SuperClass]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: X + ) -> SuperClass: ... + + return SyncOperationHandler(start) error_message = "is not compatible with the output type" @@ -211,8 +252,13 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @nexusrpc.handler.sync_operation_handler - def op(self, ctx: nexusrpc.handler.StartOperationContext, input: X) -> X: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[X, X]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: X + ) -> X: ... + + return SyncOperationHandler(start) error_message = None @@ -223,10 +269,13 @@ class Interface: op: nexusrpc.Operation[Subclass, X] class Impl: - @nexusrpc.handler.sync_operation_handler - def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: SuperClass - ) -> X: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[SuperClass, X]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: SuperClass + ) -> X: ... + + return SyncOperationHandler(start) error_message = None @@ -237,10 +286,13 @@ class Interface: op: nexusrpc.Operation[SuperClass, X] class Impl: - @nexusrpc.handler.sync_operation_handler - def op( - self, ctx: nexusrpc.handler.StartOperationContext, input: Subclass - ) -> X: ... + @nexusrpc.handler.operation_handler + def op(self) -> nexusrpc.handler.OperationHandler[Subclass, X]: + async def start( + ctx: nexusrpc.handler.StartOperationContext, input: Subclass + ) -> X: ... + + return SyncOperationHandler(start) error_message = "is not compatible with the input type" 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 index 0dae6fe..9936167 100644 --- a/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py +++ b/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py @@ -15,10 +15,8 @@ class UserServiceHandler: @nexusrpc.handler.operation_handler(name="a") def op_1(self) -> nexusrpc.handler.OperationHandler[int, int]: ... - @nexusrpc.handler.sync_operation_handler(name="a") - def op_2( - self, ctx: nexusrpc.handler.StartOperationContext, input: str - ) -> int: ... + @nexusrpc.handler.operation_handler(name="a") + def op_2(self) -> nexusrpc.handler.OperationHandler[str, int]: ... expected_error_message = ( "Operation 'a' in service 'UserServiceHandler' is defined multiple times." diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py index 9578491..488b2a5 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,29 +1,34 @@ from __future__ import annotations +import pytest + import nexusrpc.handler from nexusrpc.handler._core import ServiceHandler # TODO(preview): test operation_handler version of this -@nexusrpc.handler.service_handler -class MyServiceHandlerWithCallableInstance: - class SyncOperationWithCallableInstance: - def __call__( - self, - _handler: MyServiceHandlerWithCallableInstance, - ctx: nexusrpc.handler.StartOperationContext, - input: int, - ) -> int: - return input +if False: + + @nexusrpc.handler.service_handler + class MyServiceHandlerWithCallableInstance: + class SyncOperationWithCallableInstance: + def __call__( + self, + _handler: MyServiceHandlerWithCallableInstance, + ctx: nexusrpc.handler.StartOperationContext, + input: int, + ) -> int: + return input - sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( - name="sync_operation_with_callable_instance", - )( - SyncOperationWithCallableInstance(), - ) + sync_operation_with_callable_instance = nexusrpc.handler.operation_handler( + name="sync_operation_with_callable_instance", + )( + SyncOperationWithCallableInstance(), # type: ignore + ) +@pytest.mark.skip(reason="TODO(prerelease): update this test after decorator change") def test_service_handler_from_user_instance(): - service_handler = MyServiceHandlerWithCallableInstance() + service_handler = MyServiceHandlerWithCallableInstance() # type: ignore ServiceHandler.from_user_instance(service_handler) 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 index f161ab0..1f5f681 100644 --- 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 @@ -3,7 +3,9 @@ import pytest import nexusrpc.handler +from nexusrpc.handler import SyncOperationHandler from nexusrpc.handler._util import is_async_callable +from nexusrpc.handler.syncio import SyncOperationHandler as SyncioSyncOperationHandler @nexusrpc.handler.service_handler @@ -11,23 +13,27 @@ class MyServiceHandler: def __init__(self): self.mutable_container = [] - @nexusrpc.handler.sync_operation_handler - def my_def_op(self, ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: - """ - This is the docstring for the `my_def_op` sync operation. - """ - self.mutable_container.append(input) - return input + 1 - - @nexusrpc.handler.sync_operation_handler - async def my_async_def_op( - self, ctx: nexusrpc.handler.StartOperationContext, input: int - ) -> int: - """ - This is the docstring for the `my_async_def_op` sync operation. - """ - self.mutable_container.append(input) - return input + 2 + @nexusrpc.handler.operation_handler + def my_def_op(self) -> nexusrpc.handler.OperationHandler[int, int]: + def start(ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: + """ + This is the docstring for the `my_def_op` sync operation. + """ + self.mutable_container.append(input) + return input + 1 + + return SyncioSyncOperationHandler(start) + + @nexusrpc.handler.operation_handler + def my_async_def_op(self) -> nexusrpc.handler.OperationHandler[int, int]: + async def start(ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: + """ + This is the docstring for the `my_async_def_op` sync operation. + """ + self.mutable_container.append(input) + return input + 2 + + return SyncOperationHandler(start) def test_def_sync_handler(): From 3aaa53aafa2733905aa2c303df3b7efd70cfb571 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 23 Jun 2025 09:58:23 -0400 Subject: [PATCH 064/178] s/start_method/start/ --- src/nexusrpc/handler/_operation_handler.py | 14 +++++++------- src/nexusrpc/handler/_util.py | 12 ++++++------ src/nexusrpc/handler/syncio.py | 14 +++++++------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 66b5bcd..0c196d3 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -110,17 +110,17 @@ class SyncOperationHandler(OperationHandler[InputT, OutputT]): def __init__( self, - start_method: Callable[[StartOperationContext, InputT], Awaitable[OutputT]], + start: Callable[[StartOperationContext, InputT], Awaitable[OutputT]], ): - if not is_async_callable(start_method): + if not is_async_callable(start): raise RuntimeError( - f"{start_method} is not an `async def` method. " + 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_method = start_method - if start_method.__doc__: - self.start.__func__.__doc__ = start_method.__doc__ + self._start = start + if start.__doc__: + self.start.__func__.__doc__ = start.__doc__ async def start( self, ctx: StartOperationContext, input: InputT @@ -134,7 +134,7 @@ async def start( operation. This version of the class uses `async def` methods. For the syncio version, see :py:class:`nexusrpc.handler.syncio.SyncOperationHandler`. """ - output = await self.start_method(ctx, input) + output = await self._start(ctx, input) return StartOperationResultSync(output) async def fetch_info( diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index f21caac..07e5d38 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -21,7 +21,7 @@ def get_start_method_input_and_output_types_annotations( - start_method: Callable[ + start: Callable[ [ServiceHandlerT, StartOperationContext, InputT], Union[OutputT, Awaitable[OutputT]], ], @@ -31,14 +31,14 @@ def get_start_method_input_and_output_types_annotations( ]: """Return operation input and output types. - `start_method` must be a type-annotated start method that returns a synchronous result. + `start` must be a type-annotated start method that returns a synchronous result. """ try: - type_annotations = typing.get_type_hints(start_method) + type_annotations = typing.get_type_hints(start) except TypeError: # TODO(preview): stacklevel warnings.warn( - f"Expected decorated start method {start_method} to have type annotations" + f"Expected decorated start method {start} to have type annotations" ) return None, None output_type = type_annotations.pop("return", None) @@ -47,7 +47,7 @@ def get_start_method_input_and_output_types_annotations( # TODO(preview): stacklevel suffix = f": {type_annotations}" if type_annotations else "" warnings.warn( - f"Expected decorated start method {start_method} to have exactly 2 " + 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}." ) @@ -57,7 +57,7 @@ def get_start_method_input_and_output_types_annotations( if not issubclass(ctx_type, StartOperationContext): # TODO(preview): stacklevel warnings.warn( - f"Expected first parameter of {start_method} to be an instance of " + f"Expected first parameter of {start} to be an instance of " f"StartOperationContext, but is {ctx_type}." ) input_type = None diff --git a/src/nexusrpc/handler/syncio.py b/src/nexusrpc/handler/syncio.py index c28144f..544fe7d 100644 --- a/src/nexusrpc/handler/syncio.py +++ b/src/nexusrpc/handler/syncio.py @@ -29,17 +29,17 @@ class SyncOperationHandler(OperationHandler[InputT, OutputT]): def __init__( self, - start_method: Callable[[StartOperationContext, InputT], OutputT], + start: Callable[[StartOperationContext, InputT], OutputT], ): - if is_async_callable(start_method): + if is_async_callable(start): raise RuntimeError( - f"{start_method} is an `async def` method. " + f"{start} is an `async def` method. " "syncio.SyncOperationHandler must be initialized with a `def` method. " "To use `async def` methods, see :py:class:`nexusrpc.handler.SyncOperationHandler`." ) - self.start_method = start_method - if start_method.__doc__: - self.start.__func__.__doc__ = start_method.__doc__ + self._start = start + if start.__doc__: + self.start.__func__.__doc__ = start.__doc__ def start( self, ctx: StartOperationContext, input: InputT @@ -51,7 +51,7 @@ def start( operation. This version of the class uses `async def` methods. For the syncio version, see :py:class:`nexusrpc.handler.syncio.SyncOperationHandler`. """ - output = self.start_method(ctx, input) + output = self._start(ctx, input) return StartOperationResultSync(output) def fetch_info( From f5a1caeda1788c9eeaa32147fa36e1d5b50db1cc Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 23 Jun 2025 20:32:42 -0400 Subject: [PATCH 065/178] Implement fetch_operation_info and fetch_operation_result on Handler --- src/nexusrpc/handler/_core.py | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 7889e1f..213fb98 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -231,12 +231,48 @@ async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> Non async def fetch_operation_info( self, ctx: FetchOperationInfoContext, token: str ) -> OperationInfo: - raise NotImplementedError + 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: + if not self.executor: + raise RuntimeError( + "Operation fetch_info handler method is not an `async def` function but " + "no executor was provided to the Handler constructor." + ) + result = await self.executor.submit_to_event_loop( + op_handler.fetch_info, ctx, token + ) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation fetch_info handler method {op_handler.fetch_info} returned an " + "awaitable but is not an `async def` function." + ) + return result async def fetch_operation_result( self, ctx: FetchOperationResultContext, token: str ) -> Any: - raise NotImplementedError + 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: + if not self.executor: + raise RuntimeError( + "Operation fetch_result handler method is not an `async def` function but " + "no executor was provided to the Handler constructor." + ) + result = await self.executor.submit_to_event_loop( + op_handler.fetch_result, ctx, token + ) + if inspect.isawaitable(result): + raise RuntimeError( + f"Operation fetch_result handler method {op_handler.fetch_result} returned an " + "awaitable but is not an `async def` function." + ) + return result # TODO(prerelease): we have a syncio module now housing the syncio version of From 043f4aface7577db91ca6969b06a6ba292eafa0f Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 23 Jun 2025 20:59:47 -0400 Subject: [PATCH 066/178] Update get_start_method_input_and_output_types_annotations --- src/nexusrpc/handler/__init__.py | 2 +- src/nexusrpc/handler/_util.py | 6 +-- tests/test_get_input_and_output_types.py | 47 +++++++++++++++--------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index e1e0660..9f06973 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -37,5 +37,5 @@ SyncOperationHandler as SyncOperationHandler, ) from ._util import ( - get_start_method_input_and_output_types_annotations as get_start_method_input_and_output_types_annotations, + get_start_method_input_and_output_type_annotations as get_start_method_input_and_output_type_annotations, ) diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 07e5d38..a40b0d7 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -15,14 +15,14 @@ from typing_extensions import TypeGuard -from nexusrpc.types import InputT, OutputT, ServiceHandlerT +from nexusrpc.types import InputT, OutputT from ._common import StartOperationContext -def get_start_method_input_and_output_types_annotations( +def get_start_method_input_and_output_type_annotations( start: Callable[ - [ServiceHandlerT, StartOperationContext, InputT], + [StartOperationContext, InputT], Union[OutputT, Awaitable[OutputT]], ], ) -> tuple[ diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index de4f837..bffe90b 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -2,7 +2,6 @@ from typing import ( Any, Awaitable, - Callable, Type, Union, get_args, @@ -13,7 +12,7 @@ from nexusrpc.handler import ( StartOperationContext, - get_start_method_input_and_output_types_annotations, + get_start_method_input_and_output_type_annotations, ) @@ -26,82 +25,94 @@ class Output: class _TestCase: - start: Callable + @staticmethod + def start(ctx: StartOperationContext, i: Input) -> Output: ... + expected_types: tuple[Any, Any] class SyncMethod(_TestCase): - def start(self, ctx: StartOperationContext, i: Input) -> Output: ... + @staticmethod + def start(ctx: StartOperationContext, i: Input) -> Output: ... expected_types = (Input, Output) class AsyncMethod(_TestCase): - async def start(self, ctx: StartOperationContext, i: Input) -> Output: ... + @staticmethod + async def start(ctx: StartOperationContext, i: Input) -> Output: ... expected_types = (Input, Output) class UnionMethod(_TestCase): + @staticmethod def start( - self, ctx: StartOperationContext, i: Input + ctx: StartOperationContext, i: Input ) -> Union[Output, Awaitable[Output]]: ... expected_types = (Input, Union[Output, Awaitable[Output]]) class MissingInputAnnotationInUnionMethod(_TestCase): - def start( - self, ctx: StartOperationContext, i - ) -> Union[Output, Awaitable[Output]]: ... + @staticmethod + def start(ctx: StartOperationContext, i) -> Union[Output, Awaitable[Output]]: ... expected_types = (None, Union[Output, Awaitable[Output]]) class TooFewParams(_TestCase): - def start(self, i: Input) -> Output: ... + @staticmethod + def start(i: Input) -> Output: ... expected_types = (None, Output) class TooManyParams(_TestCase): - def start(self, ctx: StartOperationContext, i: Input, extra: int) -> Output: ... + @staticmethod + def start(ctx: StartOperationContext, i: Input, extra: int) -> Output: ... expected_types = (None, Output) class WrongOptionsType(_TestCase): - def start(self, ctx: int, i: Input) -> Output: ... + @staticmethod + def start(ctx: int, i: Input) -> Output: ... expected_types = (None, Output) class NoReturnHint(_TestCase): - def start(self, ctx: StartOperationContext, i: Input): ... + @staticmethod + def start(ctx: StartOperationContext, i: Input): ... expected_types = (Input, None) class NoInputAnnotation(_TestCase): - def start(self, ctx: StartOperationContext, i) -> Output: ... + @staticmethod + def start(ctx: StartOperationContext, i) -> Output: ... expected_types = (None, Output) class NoOptionsAnnotation(_TestCase): - def start(self, ctx, i: Input) -> Output: ... + @staticmethod + def start(ctx, i: Input) -> Output: ... expected_types = (None, Output) class AllAnnotationsMissing(_TestCase): - def start(self, ctx: StartOperationContext, i): ... + @staticmethod + def start(ctx: StartOperationContext, i): ... expected_types = (None, None) class ExplicitNoneTypes(_TestCase): - def start(self, ctx: StartOperationContext, i: None) -> None: ... + @staticmethod + def start(ctx: StartOperationContext, i: None) -> None: ... expected_types = (type(None), type(None)) @@ -126,7 +137,7 @@ def start(self, ctx: StartOperationContext, i: None) -> None: ... 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_types_annotations( + 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 From 95f7874b9666dcf69e328c117eb8a90828ee0965 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 24 Jun 2025 09:41:32 -0400 Subject: [PATCH 067/178] Delete unused get_start_method types utility --- src/nexusrpc/handler/__init__.py | 3 - src/nexusrpc/handler/_util.py | 45 ------- tests/test_get_input_and_output_types.py | 151 ----------------------- 3 files changed, 199 deletions(-) delete mode 100644 tests/test_get_input_and_output_types.py diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 9f06973..fb25d98 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -36,6 +36,3 @@ OperationHandler as OperationHandler, SyncOperationHandler as SyncOperationHandler, ) -from ._util import ( - get_start_method_input_and_output_type_annotations as get_start_method_input_and_output_type_annotations, -) diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index a40b0d7..a83d3e3 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -20,51 +20,6 @@ from ._common import StartOperationContext -def get_start_method_input_and_output_type_annotations( - start: Callable[ - [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 - - # Copied from https://github.com/modelcontextprotocol/python-sdk # # Copyright (c) 2024 Anthropic, PBC. diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py deleted file mode 100644 index bffe90b..0000000 --- a/tests/test_get_input_and_output_types.py +++ /dev/null @@ -1,151 +0,0 @@ -import warnings -from typing import ( - Any, - Awaitable, - Type, - Union, - get_args, - get_origin, -) - -import pytest - -from nexusrpc.handler import ( - StartOperationContext, - get_start_method_input_and_output_type_annotations, -) - - -class Input: - pass - - -class Output: - pass - - -class _TestCase: - @staticmethod - def start(ctx: StartOperationContext, i: Input) -> Output: ... - - expected_types: tuple[Any, Any] - - -class SyncMethod(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i: Input) -> Output: ... - - expected_types = (Input, Output) - - -class AsyncMethod(_TestCase): - @staticmethod - async def start(ctx: StartOperationContext, i: Input) -> Output: ... - - expected_types = (Input, Output) - - -class UnionMethod(_TestCase): - @staticmethod - def start( - ctx: StartOperationContext, i: Input - ) -> Union[Output, Awaitable[Output]]: ... - - expected_types = (Input, Union[Output, Awaitable[Output]]) - - -class MissingInputAnnotationInUnionMethod(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i) -> Union[Output, Awaitable[Output]]: ... - - expected_types = (None, Union[Output, Awaitable[Output]]) - - -class TooFewParams(_TestCase): - @staticmethod - def start(i: Input) -> Output: ... - - expected_types = (None, Output) - - -class TooManyParams(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i: Input, extra: int) -> Output: ... - - expected_types = (None, Output) - - -class WrongOptionsType(_TestCase): - @staticmethod - def start(ctx: int, i: Input) -> Output: ... - - expected_types = (None, Output) - - -class NoReturnHint(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i: Input): ... - - expected_types = (Input, None) - - -class NoInputAnnotation(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i) -> Output: ... - - expected_types = (None, Output) - - -class NoOptionsAnnotation(_TestCase): - @staticmethod - def start(ctx, i: Input) -> Output: ... - - expected_types = (None, Output) - - -class AllAnnotationsMissing(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i): ... - - expected_types = (None, None) - - -class ExplicitNoneTypes(_TestCase): - @staticmethod - def start(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 From 8c3be18c9aa60d6b817ac6aa5c048e1c1a9451ff Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 24 Jun 2025 22:53:41 -0400 Subject: [PATCH 068/178] Switch to SyncOperationHandler.from_callable --- src/nexusrpc/handler/_operation_handler.py | 54 ++++++++++++------ src/nexusrpc/handler/syncio.py | 55 ++++++++++++++----- tests/handler/test_handler_sync.py | 2 +- ...rrectly_functioning_operation_factories.py | 2 +- ...ator_validates_against_service_contract.py | 30 +++++----- ...corator_creates_valid_operation_handler.py | 4 +- 6 files changed, 97 insertions(+), 50 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 0c196d3..f1c6cdb 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -97,10 +97,7 @@ def cancel( ... -# TODO(prerelease): I'm worried that it will be confusing to users that they can't -# subclass this class and override the start method (currently, they would have to use -# SyncOperationHandler for that). -class SyncOperationHandler(OperationHandler[InputT, OutputT]): +class SyncOperationHandler(OperationHandler[InputT, OutputT], ABC): """ An :py:class:`OperationHandler` that is limited to responding synchronously. @@ -110,18 +107,31 @@ class SyncOperationHandler(OperationHandler[InputT, OutputT]): def __init__( self, - start: Callable[[StartOperationContext, InputT], Awaitable[OutputT]], + start: Optional[ + Callable[[StartOperationContext, InputT], Awaitable[OutputT]] + ] = None, ): - 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__: - self.start.__func__.__doc__ = start.__doc__ + if start is not None: + 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__: + self.start.__func__.__doc__ = start.__doc__ + else: + self._start = None + @classmethod + def from_callable( + cls, + start: Callable[[StartOperationContext, InputT], Awaitable[OutputT]], + ) -> SyncOperationHandler[InputT, OutputT]: + return _SyncOperationHandler(start) + + @abstractmethod async def start( self, ctx: StartOperationContext, input: InputT ) -> StartOperationResultSync[OutputT]: @@ -134,8 +144,7 @@ async def start( operation. This version of the class uses `async def` methods. For the syncio version, see :py:class:`nexusrpc.handler.syncio.SyncOperationHandler`. """ - output = await self._start(ctx, input) - return StartOperationResultSync(output) + ... async def fetch_info( self, ctx: FetchOperationInfoContext, token: str @@ -159,6 +168,19 @@ async def cancel( ) +class _SyncOperationHandler(SyncOperationHandler[InputT, OutputT]): + async def start( + self, ctx: StartOperationContext, input: InputT + ) -> StartOperationResultSync[OutputT]: + if self._start is None: + raise RuntimeError( + "Do not use _SyncOperationHandler directly. " + "Use SyncOperationHandler.from_callable instead." + ) + output = await self._start(ctx, input) + return StartOperationResultSync(output) + + def collect_operation_handler_factories( user_service_cls: Type[ServiceHandlerT], service: Optional[nexusrpc.ServiceDefinition], diff --git a/src/nexusrpc/handler/syncio.py b/src/nexusrpc/handler/syncio.py index 544fe7d..447cba2 100644 --- a/src/nexusrpc/handler/syncio.py +++ b/src/nexusrpc/handler/syncio.py @@ -1,8 +1,10 @@ from __future__ import annotations +from abc import ABC, abstractmethod from typing import ( Awaitable, Callable, + Optional, Union, ) @@ -19,7 +21,7 @@ from nexusrpc.types import InputT, OutputT -class SyncOperationHandler(OperationHandler[InputT, OutputT]): +class SyncOperationHandler(OperationHandler[InputT, OutputT], ABC): """ An :py:class:`OperationHandler` that is limited to responding synchronously. @@ -29,18 +31,29 @@ class SyncOperationHandler(OperationHandler[InputT, OutputT]): def __init__( self, - start: Callable[[StartOperationContext, InputT], OutputT], + start: Optional[Callable[[StartOperationContext, InputT], OutputT]] = None, ): - if is_async_callable(start): - raise RuntimeError( - f"{start} is an `async def` method. " - "syncio.SyncOperationHandler must be initialized with a `def` method. " - "To use `async def` methods, see :py:class:`nexusrpc.handler.SyncOperationHandler`." - ) - self._start = start - if start.__doc__: - self.start.__func__.__doc__ = start.__doc__ + if start is not None: + if is_async_callable(start): + raise RuntimeError( + f"{start} is an `async def` method. " + "syncio.SyncOperationHandler must be initialized with a `def` method. " + "To use `async def` methods, see :py:class:`nexusrpc.handler.SyncOperationHandler`." + ) + self._start = start + if start.__doc__: + self.start.__func__.__doc__ = start.__doc__ + else: + self._start = None + + @classmethod + def from_callable( + cls, + start: Callable[[StartOperationContext, InputT], OutputT], + ) -> SyncOperationHandler[InputT, OutputT]: + return _SyncOperationHandler(start) + @abstractmethod def start( self, ctx: StartOperationContext, input: InputT ) -> StartOperationResultSync[OutputT]: @@ -48,11 +61,10 @@ def start( 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`. + operation. This version of the class uses `def` methods. For the `async def` + version, see :py:class:`nexusrpc.handler.SyncOperationHandler`. """ - output = self._start(ctx, input) - return StartOperationResultSync(output) + ... def fetch_info( self, ctx: FetchOperationInfoContext, token: str @@ -74,3 +86,16 @@ def cancel( raise NotImplementedError( "An operation that responded synchronously cannot be cancelled." ) + + +class _SyncOperationHandler(SyncOperationHandler[InputT, OutputT]): + def start( + self, ctx: StartOperationContext, input: InputT + ) -> StartOperationResultSync[OutputT]: + if self._start is None: + raise RuntimeError( + "Do not use _SyncOperationHandler directly. " + "Use SyncOperationHandler.from_callable instead." + ) + output = self._start(ctx, input) + return StartOperationResultSync(output) diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_sync.py index 9bece1e..71465dc 100644 --- a/tests/handler/test_handler_sync.py +++ b/tests/handler/test_handler_sync.py @@ -28,7 +28,7 @@ def incr(self) -> OperationHandler[int, int]: def start(ctx: StartOperationContext, input: int) -> int: return input + 1 - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) user_service_handler = MyService() 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 index 763fd61..b540bb5 100644 --- 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 @@ -62,7 +62,7 @@ async def start( ) -> int: return 7 - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) expected_operation_factories = {"sync_operation_handler": 7} 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 index 10a2e0e..e20f3be 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -27,7 +27,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: None ) -> None: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -44,7 +44,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: None ) -> None: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) def unrelated_method(self) -> None: ... @@ -61,7 +61,7 @@ class Impl: def op(self): async def start(ctx, input): ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -89,7 +89,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input ) -> None: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -108,7 +108,7 @@ async def start( input: str, ) -> None: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = "is not compatible with the input type" @@ -125,7 +125,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: None ) -> str: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = "is not compatible with the output type" @@ -142,7 +142,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: str ) -> str: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = "is not compatible with the output type" @@ -159,7 +159,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: str ) -> None: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -176,7 +176,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: str ) -> str: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -205,7 +205,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: X ) -> X: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -222,7 +222,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: X ) -> Subclass: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -241,7 +241,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: X ) -> SuperClass: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = "is not compatible with the output type" @@ -258,7 +258,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: X ) -> X: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -275,7 +275,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: SuperClass ) -> X: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = None @@ -292,7 +292,7 @@ async def start( ctx: nexusrpc.handler.StartOperationContext, input: Subclass ) -> X: ... - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) error_message = "is not compatible with the input type" 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 index 1f5f681..7eb03b6 100644 --- 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 @@ -22,7 +22,7 @@ def start(ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: self.mutable_container.append(input) return input + 1 - return SyncioSyncOperationHandler(start) + return SyncioSyncOperationHandler.from_callable(start) @nexusrpc.handler.operation_handler def my_async_def_op(self) -> nexusrpc.handler.OperationHandler[int, int]: @@ -33,7 +33,7 @@ async def start(ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: self.mutable_container.append(input) return input + 2 - return SyncOperationHandler(start) + return SyncOperationHandler.from_callable(start) def test_def_sync_handler(): From 721f3d130a2abf65b2af1ad2abbb740aae9b948f Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 24 Jun 2025 23:09:59 -0400 Subject: [PATCH 069/178] Rename test --- tests/handler/{test_handler_sync.py => test_handler_syncio.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/handler/{test_handler_sync.py => test_handler_syncio.py} (100%) diff --git a/tests/handler/test_handler_sync.py b/tests/handler/test_handler_syncio.py similarity index 100% rename from tests/handler/test_handler_sync.py rename to tests/handler/test_handler_syncio.py From 770ffbc6852ca19ad8609f1945bc4ae589a82f1b Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 24 Jun 2025 23:22:03 -0400 Subject: [PATCH 070/178] Move OperationInfo to top level --- src/nexusrpc/__init__.py | 13 +++++++++++++ src/nexusrpc/handler/__init__.py | 1 - src/nexusrpc/handler/_common.py | 13 ------------- src/nexusrpc/handler/_core.py | 2 +- src/nexusrpc/handler/_operation_handler.py | 2 +- src/nexusrpc/handler/syncio.py | 2 +- ..._handler_validates_service_handler_collection.py | 2 +- ..._in_correctly_functioning_operation_factories.py | 2 +- 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index b63dbe2..11ea68f 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -33,3 +33,16 @@ class OperationState(Enum): FAILED = "failed" CANCELED = "canceled" RUNNING = "running" + + +@dataclass +class OperationInfo: + """ + Information about an operation. + """ + + # Token identifying the operation (returned on operation start). + token: str + + # The operation's state + state: OperationState diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index fb25d98..c78bcf9 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -19,7 +19,6 @@ OperationContext as OperationContext, OperationError as OperationError, OperationErrorState as OperationErrorState, - OperationInfo as OperationInfo, StartOperationContext as StartOperationContext, StartOperationResultAsync as StartOperationResultAsync, StartOperationResultSync as StartOperationResultSync, diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index a2d509d..46c098a 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -149,19 +149,6 @@ class FetchOperationResultContext(OperationContext): Includes information from the request.""" -@dataclass -class OperationInfo: - """ - Information about an operation. - """ - - # Token identifying the operation (returned on operation start). - token: str - - # The operation's state - state: OperationState - - # TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? @dataclass class StartOperationResultSync(Generic[OutputT]): diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 213fb98..c852745 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -20,6 +20,7 @@ import nexusrpc._service from nexusrpc.handler._util import is_async_callable +from .. import OperationInfo from .._serializer import LazyValue from ._common import ( CancelOperationContext, @@ -27,7 +28,6 @@ FetchOperationResultContext, HandlerError, HandlerErrorType, - OperationInfo, StartOperationContext, StartOperationResultAsync, StartOperationResultSync, diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index f1c6cdb..bbe04dc 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -19,11 +19,11 @@ from nexusrpc.handler._util import is_async_callable from nexusrpc.types import InputT, OutputT, ServiceHandlerT +from .. import OperationInfo from ._common import ( CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, - OperationInfo, StartOperationContext, StartOperationResultAsync, StartOperationResultSync, diff --git a/src/nexusrpc/handler/syncio.py b/src/nexusrpc/handler/syncio.py index 447cba2..a837ead 100644 --- a/src/nexusrpc/handler/syncio.py +++ b/src/nexusrpc/handler/syncio.py @@ -8,12 +8,12 @@ Union, ) +from nexusrpc import OperationInfo from nexusrpc.handler import ( CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, OperationHandler, - OperationInfo, StartOperationContext, StartOperationResultSync, ) diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index b2d7023..f9e992c 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -35,7 +35,7 @@ async def fetch_info( self, ctx: nexusrpc.handler.FetchOperationInfoContext, token: str, - ) -> nexusrpc.handler.OperationInfo: ... + ) -> nexusrpc.OperationInfo: ... async def fetch_result( self, 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 index b540bb5..c986975 100644 --- 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 @@ -34,7 +34,7 @@ async def start( def fetch_info( self, ctx: nexusrpc.handler.FetchOperationInfoContext, token: str - ) -> nexusrpc.handler.OperationInfo: + ) -> nexusrpc.OperationInfo: raise NotImplementedError def fetch_result( From 5a88b8531a2a350b166b1c6dfdc515ea829b736c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 24 Jun 2025 23:23:08 -0400 Subject: [PATCH 071/178] Cleanup --- src/nexusrpc/_serializer.py | 2 ++ src/nexusrpc/handler/_common.py | 2 +- src/nexusrpc/handler/_core.py | 4 ++-- src/nexusrpc/handler/_util.py | 9 --------- tests/handler/test_handler_syncio.py | 2 +- 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index 38f87c7..0355f82 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -102,6 +102,8 @@ async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: as_type=as_type, ) + # TODO(prerelease): we have a syncio module now for the syncio version of SyncOperationHandler + # SHould this go in a syncio module? def consume_sync(self, as_type: Optional[Type[Any]] = None) -> Any: """ Consume the underlying reader stream, deserializing via the embedded serializer. diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 46c098a..f8f4c7c 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -9,7 +9,7 @@ Sequence, ) -from nexusrpc import Link, OperationState +from nexusrpc import Link from nexusrpc.types import OutputT diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index c852745..a753360 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -87,11 +87,11 @@ def __init__( for op_name, operation_handler in sh.operation_handlers.items(): if not is_async_callable(operation_handler.start): raise RuntimeError( - f"Service '{sh.service.name}' operation '{op_name}' start must be an `async def` if no executor is provided." + f"Service '{sh.service.name}' operation '{op_name}' start method must be an `async def` if no executor is provided." ) if not is_async_callable(operation_handler.cancel): raise RuntimeError( - f"Service '{sh.service.name}' operation '{op_name}' cancel must be an `async def` if no executor is provided." + f"Service '{sh.service.name}' operation '{op_name}' cancel method must be an `async def` if no executor is provided." ) self.service_handlers[sh.service.name] = sh diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index a83d3e3..93c8613 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -2,23 +2,14 @@ import functools import inspect -import typing -import warnings from typing import ( Any, Awaitable, Callable, - Optional, - Type, - Union, ) from typing_extensions import TypeGuard -from nexusrpc.types import InputT, OutputT - -from ._common import StartOperationContext - # Copied from https://github.com/modelcontextprotocol/python-sdk # diff --git a/tests/handler/test_handler_syncio.py b/tests/handler/test_handler_syncio.py index 71465dc..ad3220d 100644 --- a/tests/handler/test_handler_syncio.py +++ b/tests/handler/test_handler_syncio.py @@ -4,7 +4,7 @@ import pytest -from nexusrpc._serializer import Content, LazyValue +from nexusrpc import Content, LazyValue from nexusrpc.handler import ( OperationHandler, StartOperationContext, From a60cb3a6539b6a50c7264c9f1085940a906dc49d Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 24 Jun 2025 23:38:07 -0400 Subject: [PATCH 072/178] Test all operation methods --- tests/handler/test_async_operation.py | 104 ++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/handler/test_async_operation.py diff --git a/tests/handler/test_async_operation.py b/tests/handler/test_async_operation.py new file mode 100644 index 0000000..080e641 --- /dev/null +++ b/tests/handler/test_async_operation.py @@ -0,0 +1,104 @@ +import uuid +from dataclasses import dataclass +from typing import Any, Optional, Type + +import pytest + +from nexusrpc import Content, LazyValue, OperationInfo, OperationState +from nexusrpc.handler import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + Handler, + OperationHandler, + StartOperationContext, + StartOperationResultAsync, + operation_handler, + service_handler, +) + + +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: + 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", + ) + 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", + ) + 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", + ) + result = await handler.fetch_operation_result(fetch_result_ctx, start_result.token) + assert result == 2 + + cancel_ctx = CancelOperationContext( + service="MyService", + operation="incr", + ) + await handler.cancel_operation(cancel_ctx, start_result.token) + assert start_result.token not in _operation_results + + +@dataclass +class DummySerializer: + value: int + + 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 From 964a99a52385b8f2cc102249d0c647a4ceafb4b2 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 25 Jun 2025 13:50:31 -0400 Subject: [PATCH 073/178] Cleanup --- src/nexusrpc/handler/_operation_handler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index bbe04dc..37dfebb 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -202,7 +202,6 @@ def collect_operation_handler_factories( op_defn = getattr(method, "__nexus_operation__", None) if isinstance(op_defn, nexusrpc.Operation): # This is a method decorated with one of the *operation_handler decorators - # assert op_defn.name == name if op_defn.name in factories: raise RuntimeError( f"Operation '{op_defn.name}' in service '{user_service_cls.__name__}' " From 69c501dea6b38112683b07658ae490fdf0d44c94 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 25 Jun 2025 14:42:59 -0400 Subject: [PATCH 074/178] Cleanup --- ...rrectly_functioning_operation_factories.py | 60 ++++---- ...ator_validates_against_service_contract.py | 133 ++++++++---------- ...corator_creates_valid_operation_handler.py | 32 +++-- 3 files changed, 107 insertions(+), 118 deletions(-) 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 index c986975..d81cf43 100644 --- 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 @@ -8,9 +8,19 @@ import pytest import nexusrpc._service -import nexusrpc.handler +from nexusrpc.handler import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + OperationHandler, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, + SyncOperationHandler, + operation_handler, +) from nexusrpc.handler._core import collect_operation_handler_factories -from nexusrpc.handler._operation_handler import SyncOperationHandler +from nexusrpc.handler._decorators import service_handler from nexusrpc.handler._util import is_async_callable from nexusrpc.types import InputT, OutputT @@ -22,29 +32,27 @@ class _TestCase: class ManualOperationDefinition(_TestCase): - @nexusrpc.handler.service_handler + @service_handler class Service: - @nexusrpc.handler.operation_handler - def operation(self) -> nexusrpc.handler.OperationHandler[int, int]: - class OpHandler(nexusrpc.handler.OperationHandler[int, int]): + @operation_handler + def operation(self) -> OperationHandler[int, int]: + class OpHandler(OperationHandler[int, int]): async def start( - self, ctx: nexusrpc.handler.StartOperationContext, input: int - ) -> nexusrpc.handler.StartOperationResultSync[int]: - return nexusrpc.handler.StartOperationResultSync(7) + self, ctx: StartOperationContext, input: int + ) -> StartOperationResultSync[int]: + return StartOperationResultSync(7) def fetch_info( - self, ctx: nexusrpc.handler.FetchOperationInfoContext, token: str + self, ctx: FetchOperationInfoContext, token: str ) -> nexusrpc.OperationInfo: raise NotImplementedError def fetch_result( - self, ctx: nexusrpc.handler.FetchOperationResultContext, token: str + self, ctx: FetchOperationResultContext, token: str ) -> int: raise NotImplementedError - def cancel( - self, ctx: nexusrpc.handler.CancelOperationContext, token: str - ) -> None: + def cancel(self, ctx: CancelOperationContext, token: str) -> None: raise NotImplementedError return OpHandler() @@ -53,13 +61,11 @@ def cancel( class SyncOperation(_TestCase): - @nexusrpc.handler.service_handler + @service_handler class Service: - @nexusrpc.handler.operation_handler - def sync_operation_handler(self) -> nexusrpc.handler.OperationHandler[int, int]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: int - ) -> int: + @operation_handler + def sync_operation_handler(self) -> OperationHandler[int, int]: + async def start(ctx: StartOperationContext, input: int) -> int: return 7 return SyncOperationHandler.from_callable(start) @@ -87,24 +93,24 @@ async def test_collected_operation_factories_match_service_definition( test_case.Service, service ) assert operation_factories.keys() == test_case.expected_operation_factories.keys() - ctx = nexusrpc.handler.StartOperationContext( + ctx = StartOperationContext( service="Service", operation="operation", ) async def execute( - op: nexusrpc.handler.OperationHandler[InputT, OutputT], - ctx: nexusrpc.handler.StartOperationContext, + op: OperationHandler[InputT, OutputT], + ctx: StartOperationContext, input: InputT, ) -> Union[ - nexusrpc.handler.StartOperationResultSync[OutputT], - nexusrpc.handler.StartOperationResultAsync, + StartOperationResultSync[OutputT], + StartOperationResultAsync, ]: if is_async_callable(op.start): return await op.start(ctx, input) else: return cast( - nexusrpc.handler.StartOperationResultSync[OutputT], + StartOperationResultSync[OutputT], op.start(ctx, input), ) @@ -112,5 +118,5 @@ async def execute( op_factory = operation_factories[op_name] op = op_factory(test_case.Service) result = await execute(op, ctx, 0) - assert isinstance(result, nexusrpc.handler.StartOperationResultSync) + assert isinstance(result, StartOperationResultSync) assert result.value == expected_result 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 index e20f3be..840c110 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -3,8 +3,13 @@ import pytest import nexusrpc -import nexusrpc.handler -from nexusrpc.handler._operation_handler import SyncOperationHandler +from nexusrpc.handler import ( + OperationHandler, + StartOperationContext, + SyncOperationHandler, + operation_handler, + service_handler, +) class _InterfaceImplementationTestCase: @@ -21,11 +26,9 @@ class Interface: def unrelated_method(self) -> None: ... class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[None, None]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: None - ) -> None: ... + @operation_handler + def op(self) -> OperationHandler[None, None]: + async def start(ctx: StartOperationContext, input: None) -> None: ... return SyncOperationHandler.from_callable(start) @@ -38,11 +41,9 @@ class Interface: pass class Impl: - @nexusrpc.handler.operation_handler - def extra_op(self) -> nexusrpc.handler.OperationHandler[None, None]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: None - ) -> None: ... + @operation_handler + def extra_op(self) -> OperationHandler[None, None]: + async def start(ctx: StartOperationContext, input: None) -> None: ... return SyncOperationHandler.from_callable(start) @@ -57,7 +58,7 @@ class Interface: op: nexusrpc.Operation[int, str] class Impl: - @nexusrpc.handler.operation_handler + @operation_handler def op(self): async def start(ctx, input): ... @@ -83,11 +84,9 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[None, None]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input - ) -> None: ... + @operation_handler + def op(self) -> OperationHandler[None, None]: + async def start(ctx: StartOperationContext, input) -> None: ... return SyncOperationHandler.from_callable(start) @@ -100,11 +99,11 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[str, None]: + @operation_handler + def op(self) -> OperationHandler[str, None]: async def start( # TODO(prerelease) isn't this supposed to be missing the ctx annotation? - ctx: nexusrpc.handler.StartOperationContext, + ctx: StartOperationContext, input: str, ) -> None: ... @@ -119,11 +118,9 @@ class Interface: op: nexusrpc.Operation[None, int] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[None, str]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: None - ) -> str: ... + @operation_handler + def op(self) -> OperationHandler[None, str]: + async def start(ctx: StartOperationContext, input: None) -> str: ... return SyncOperationHandler.from_callable(start) @@ -136,11 +133,9 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[str, str]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: str - ) -> str: ... + @operation_handler + def op(self) -> OperationHandler[str, str]: + async def start(ctx: StartOperationContext, input: str) -> str: ... return SyncOperationHandler.from_callable(start) @@ -153,11 +148,9 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[str, None]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: str - ) -> None: ... + @operation_handler + def op(self) -> OperationHandler[str, None]: + async def start(ctx: StartOperationContext, input: str) -> None: ... return SyncOperationHandler.from_callable(start) @@ -170,11 +163,9 @@ class Interface: op: nexusrpc.Operation[Any, Any] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[str, str]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: str - ) -> str: ... + @operation_handler + def op(self) -> OperationHandler[str, str]: + async def start(ctx: StartOperationContext, input: str) -> str: ... return SyncOperationHandler.from_callable(start) @@ -199,11 +190,9 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[X, X]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: X - ) -> X: ... + @operation_handler + def op(self) -> OperationHandler[X, X]: + async def start(ctx: StartOperationContext, input: X) -> X: ... return SyncOperationHandler.from_callable(start) @@ -216,11 +205,9 @@ class Interface: op: nexusrpc.Operation[X, SuperClass] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[X, Subclass]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: X - ) -> Subclass: ... + @operation_handler + def op(self) -> OperationHandler[X, Subclass]: + async def start(ctx: StartOperationContext, input: X) -> Subclass: ... return SyncOperationHandler.from_callable(start) @@ -235,11 +222,9 @@ class Interface: op: nexusrpc.Operation[X, Subclass] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[X, SuperClass]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: X - ) -> SuperClass: ... + @operation_handler + def op(self) -> OperationHandler[X, SuperClass]: + async def start(ctx: StartOperationContext, input: X) -> SuperClass: ... return SyncOperationHandler.from_callable(start) @@ -252,11 +237,9 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[X, X]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: X - ) -> X: ... + @operation_handler + def op(self) -> OperationHandler[X, X]: + async def start(ctx: StartOperationContext, input: X) -> X: ... return SyncOperationHandler.from_callable(start) @@ -269,11 +252,9 @@ class Interface: op: nexusrpc.Operation[Subclass, X] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[SuperClass, X]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: SuperClass - ) -> X: ... + @operation_handler + def op(self) -> OperationHandler[SuperClass, X]: + async def start(ctx: StartOperationContext, input: SuperClass) -> X: ... return SyncOperationHandler.from_callable(start) @@ -286,11 +267,9 @@ class Interface: op: nexusrpc.Operation[SuperClass, X] class Impl: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[Subclass, X]: - async def start( - ctx: nexusrpc.handler.StartOperationContext, input: Subclass - ) -> X: ... + @operation_handler + def op(self) -> OperationHandler[Subclass, X]: + async def start(ctx: StartOperationContext, input: Subclass) -> X: ... return SyncOperationHandler.from_callable(start) @@ -325,13 +304,11 @@ def test_service_decorator_enforces_interface_implementation( ): if test_case.error_message: with pytest.raises(Exception) as ei: - nexusrpc.handler.service_handler(service=test_case.Interface)( - test_case.Impl - ) + service_handler(service=test_case.Interface)(test_case.Impl) err = ei.value assert test_case.error_message in str(err) else: - nexusrpc.handler.service_handler(service=test_case.Interface)(test_case.Impl) + service_handler(service=test_case.Interface)(test_case.Impl) # TODO(preview): duplicate test? @@ -341,11 +318,11 @@ class Contract: operation_a: nexusrpc.Operation[None, None] class Service: - @nexusrpc.handler.operation_handler - def operation_b(self) -> nexusrpc.handler.OperationHandler[None, None]: ... + @operation_handler + def operation_b(self) -> OperationHandler[None, None]: ... with pytest.raises( TypeError, match="does not match an operation method name in the service definition", ): - nexusrpc.handler.service_handler(service=Contract)(Service) + service_handler(service=Contract)(Service) 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 index 7eb03b6..bbc6bae 100644 --- 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 @@ -2,20 +2,26 @@ import pytest -import nexusrpc.handler -from nexusrpc.handler import SyncOperationHandler +from nexusrpc.handler import ( + OperationHandler, + StartOperationContext, + StartOperationResultSync, + SyncOperationHandler, + operation_handler, + service_handler, +) from nexusrpc.handler._util import is_async_callable from nexusrpc.handler.syncio import SyncOperationHandler as SyncioSyncOperationHandler -@nexusrpc.handler.service_handler +@service_handler class MyServiceHandler: def __init__(self): self.mutable_container = [] - @nexusrpc.handler.operation_handler - def my_def_op(self) -> nexusrpc.handler.OperationHandler[int, int]: - def start(ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: + @operation_handler + def my_def_op(self) -> OperationHandler[int, int]: + def start(ctx: StartOperationContext, input: int) -> int: """ This is the docstring for the `my_def_op` sync operation. """ @@ -24,9 +30,9 @@ def start(ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: return SyncioSyncOperationHandler.from_callable(start) - @nexusrpc.handler.operation_handler - def my_async_def_op(self) -> nexusrpc.handler.OperationHandler[int, int]: - async def start(ctx: nexusrpc.handler.StartOperationContext, input: int) -> int: + @operation_handler + def my_async_def_op(self) -> OperationHandler[int, int]: + async def start(ctx: StartOperationContext, input: int) -> int: """ This is the docstring for the `my_async_def_op` sync operation. """ @@ -45,9 +51,9 @@ def test_def_sync_handler(): == "This is the docstring for the `my_def_op` sync operation." ) assert not user_instance.mutable_container - ctx = mock.Mock(spec=nexusrpc.handler.StartOperationContext) + ctx = mock.Mock(spec=StartOperationContext) result = op_handler.start(ctx, 1) - assert isinstance(result, nexusrpc.handler.StartOperationResultSync) + assert isinstance(result, StartOperationResultSync) assert result.value == 2 assert user_instance.mutable_container == [1] @@ -62,8 +68,8 @@ async def test_async_def_sync_handler(): == "This is the docstring for the `my_async_def_op` sync operation." ) assert not user_instance.mutable_container - ctx = mock.Mock(spec=nexusrpc.handler.StartOperationContext) + ctx = mock.Mock(spec=StartOperationContext) result = await op_handler.start(ctx, 1) - assert isinstance(result, nexusrpc.handler.StartOperationResultSync) + assert isinstance(result, StartOperationResultSync) assert result.value == 3 assert user_instance.mutable_container == [1] From b6e792ccc2ab3598106932492aca739d094746c8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 25 Jun 2025 14:24:52 -0400 Subject: [PATCH 075/178] Revert "Delete unused get_start_method types utility" This reverts commit 95f7874b9666dcf69e328c117eb8a90828ee0965. --- src/nexusrpc/handler/__init__.py | 3 + src/nexusrpc/handler/_util.py | 51 ++++++++ tests/test_get_input_and_output_types.py | 151 +++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 tests/test_get_input_and_output_types.py diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index c78bcf9..b1d4273 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -35,3 +35,6 @@ OperationHandler as OperationHandler, SyncOperationHandler as SyncOperationHandler, ) +from ._util import ( + get_start_method_input_and_output_type_annotations as get_start_method_input_and_output_type_annotations, +) diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 93c8613..779cecc 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -2,14 +2,65 @@ import functools import inspect +import typing +import warnings from typing import ( Any, Awaitable, Callable, + Optional, + Type, ) from typing_extensions import TypeGuard +from nexusrpc.handler._common import StartOperationContext +from nexusrpc.types import InputT, OutputT, ServiceHandlerT + + +def get_start_method_input_and_output_type_annotations( + start: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], 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 + # Copied from https://github.com/modelcontextprotocol/python-sdk # 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..bffe90b --- /dev/null +++ b/tests/test_get_input_and_output_types.py @@ -0,0 +1,151 @@ +import warnings +from typing import ( + Any, + Awaitable, + Type, + Union, + get_args, + get_origin, +) + +import pytest + +from nexusrpc.handler import ( + StartOperationContext, + get_start_method_input_and_output_type_annotations, +) + + +class Input: + pass + + +class Output: + pass + + +class _TestCase: + @staticmethod + def start(ctx: StartOperationContext, i: Input) -> Output: ... + + expected_types: tuple[Any, Any] + + +class SyncMethod(_TestCase): + @staticmethod + def start(ctx: StartOperationContext, i: Input) -> Output: ... + + expected_types = (Input, Output) + + +class AsyncMethod(_TestCase): + @staticmethod + async def start(ctx: StartOperationContext, i: Input) -> Output: ... + + expected_types = (Input, Output) + + +class UnionMethod(_TestCase): + @staticmethod + def start( + ctx: StartOperationContext, i: Input + ) -> Union[Output, Awaitable[Output]]: ... + + expected_types = (Input, Union[Output, Awaitable[Output]]) + + +class MissingInputAnnotationInUnionMethod(_TestCase): + @staticmethod + def start(ctx: StartOperationContext, i) -> Union[Output, Awaitable[Output]]: ... + + expected_types = (None, Union[Output, Awaitable[Output]]) + + +class TooFewParams(_TestCase): + @staticmethod + def start(i: Input) -> Output: ... + + expected_types = (None, Output) + + +class TooManyParams(_TestCase): + @staticmethod + def start(ctx: StartOperationContext, i: Input, extra: int) -> Output: ... + + expected_types = (None, Output) + + +class WrongOptionsType(_TestCase): + @staticmethod + def start(ctx: int, i: Input) -> Output: ... + + expected_types = (None, Output) + + +class NoReturnHint(_TestCase): + @staticmethod + def start(ctx: StartOperationContext, i: Input): ... + + expected_types = (Input, None) + + +class NoInputAnnotation(_TestCase): + @staticmethod + def start(ctx: StartOperationContext, i) -> Output: ... + + expected_types = (None, Output) + + +class NoOptionsAnnotation(_TestCase): + @staticmethod + def start(ctx, i: Input) -> Output: ... + + expected_types = (None, Output) + + +class AllAnnotationsMissing(_TestCase): + @staticmethod + def start(ctx: StartOperationContext, i): ... + + expected_types = (None, None) + + +class ExplicitNoneTypes(_TestCase): + @staticmethod + def start(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 From a938cfd2f4274ffbb859a94166b120c8e2875463 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 25 Jun 2025 14:24:33 -0400 Subject: [PATCH 076/178] New version of sync_operation decorator --- src/nexusrpc/handler/__init__.py | 7 +- src/nexusrpc/handler/_decorators.py | 51 ++++++- src/nexusrpc/handler/_operation_handler.py | 107 +++++---------- src/nexusrpc/handler/_util.py | 29 +++- ...collects_expected_operation_definitions.py | 11 -- ...rrectly_functioning_operation_factories.py | 17 ++- ...ator_validates_against_service_contract.py | 124 ++++++------------ ...corator_creates_valid_operation_handler.py | 52 ++++---- tests/test_get_input_and_output_types.py | 45 +++---- 9 files changed, 197 insertions(+), 246 deletions(-) diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index b1d4273..698553e 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -30,11 +30,10 @@ from ._decorators import ( operation_handler as operation_handler, service_handler as service_handler, + sync_operation_handler as sync_operation_handler, ) -from ._operation_handler import ( - OperationHandler as OperationHandler, - SyncOperationHandler as SyncOperationHandler, -) +from ._operation_handler import OperationHandler as OperationHandler from ._util import ( + get_operation_factory as get_operation_factory, get_start_method_input_and_output_type_annotations as get_start_method_input_and_output_type_annotations, ) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 29769fd..a5ac812 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -4,6 +4,7 @@ import warnings from typing import ( Any, + Awaitable, Callable, Optional, Type, @@ -13,12 +14,15 @@ ) import nexusrpc -from nexusrpc.types import ServiceHandlerT +from nexusrpc.handler._common import StartOperationContext +from nexusrpc.handler._util import get_start_method_input_and_output_type_annotations +from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._operation_handler import ( OperationHandler, + SyncOperationHandler, collect_operation_handler_factories, - service_from_operation_handler_methods, + service_definition_from_operation_handler_methods, validate_operation_handler_methods, ) @@ -105,7 +109,7 @@ def decorator(cls: Type[ServiceHandlerT]) -> Type[ServiceHandlerT]: raise ValueError("Service name must not be empty.") op_factories = collect_operation_handler_factories(cls, _service) - service = _service or service_from_operation_handler_methods( + service = _service or service_definition_from_operation_handler_methods( _name, op_factories ) validate_operation_handler_methods(cls, op_factories, service) @@ -197,3 +201,44 @@ def decorator( return decorator return decorator(method) + + +def sync_operation_handler( + start: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + ], +) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: + """ + Decorator marking a method as the start method for a synchronous operation. + """ + + def decorator( + start: Callable[ + [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + ], + ) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: + 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( + start + ) + + operation_handler_factory.__nexus_operation__ = nexusrpc.Operation( + # TODO(prerelease): extend decorator to support name override param + name=start.__name__, + method_name=start.__name__, + input_type=input_type, + output_type=output_type, + ) + + start.__nexus_operation_factory__ = operation_handler_factory + return start + + return decorator(start) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 37dfebb..01c8706 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -1,8 +1,6 @@ from __future__ import annotations import inspect -import typing -import warnings from abc import ABC, abstractmethod from typing import ( Any, @@ -16,7 +14,7 @@ import nexusrpc import nexusrpc._service -from nexusrpc.handler._util import is_async_callable +from nexusrpc.handler._util import get_operation_factory, is_async_callable from nexusrpc.types import InputT, OutputT, ServiceHandlerT from .. import OperationInfo @@ -97,7 +95,7 @@ def cancel( ... -class SyncOperationHandler(OperationHandler[InputT, OutputT], ABC): +class SyncOperationHandler(OperationHandler[InputT, OutputT]): """ An :py:class:`OperationHandler` that is limited to responding synchronously. @@ -106,32 +104,18 @@ class SyncOperationHandler(OperationHandler[InputT, OutputT], ABC): """ def __init__( - self, - start: Optional[ - Callable[[StartOperationContext, InputT], Awaitable[OutputT]] - ] = None, + self, start: Callable[[StartOperationContext, InputT], Awaitable[OutputT]] ): - if start is not None: - 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__: - self.start.__func__.__doc__ = start.__doc__ - else: - self._start = None - - @classmethod - def from_callable( - cls, - start: Callable[[StartOperationContext, InputT], Awaitable[OutputT]], - ) -> SyncOperationHandler[InputT, OutputT]: - return _SyncOperationHandler(start) + 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__: + self.start.__func__.__doc__ = start.__doc__ - @abstractmethod async def start( self, ctx: StartOperationContext, input: InputT ) -> StartOperationResultSync[OutputT]: @@ -144,7 +128,7 @@ async def start( 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 @@ -168,19 +152,6 @@ async def cancel( ) -class _SyncOperationHandler(SyncOperationHandler[InputT, OutputT]): - async def start( - self, ctx: StartOperationContext, input: InputT - ) -> StartOperationResultSync[OutputT]: - if self._start is None: - raise RuntimeError( - "Do not use _SyncOperationHandler directly. " - "Use SyncOperationHandler.from_callable instead." - ) - output = await self._start(ctx, input) - return StartOperationResultSync(output) - - def collect_operation_handler_factories( user_service_cls: Type[ServiceHandlerT], service: Optional[nexusrpc.ServiceDefinition], @@ -199,7 +170,7 @@ def collect_operation_handler_factories( else set() ) for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): - op_defn = getattr(method, "__nexus_operation__", None) + factory, op_defn = get_operation_factory(method) if isinstance(op_defn, nexusrpc.Operation): # This is a method decorated with one of the *operation_handler decorators if op_defn.name in factories: @@ -215,20 +186,7 @@ def collect_operation_handler_factories( f"Available method names in the service definition: {_names}." ) - factories[op_defn.name] = method - # Check for accidentally missing decorator on an OperationHandler factory - # TODO(preview): support disabling warning in @service_handler decorator? - elif ( - typing.get_origin(typing.get_type_hints(method).get("return")) - == OperationHandler - ): - warnings.warn( - f"Method '{method}' in class '{user_service_cls}' " - f"returns OperationHandler but has not been decorated. " - f"Did you forget to apply the @nexusrpc.handler.operation_handler decorator?", - UserWarning, - stacklevel=2, - ) + factories[op_defn.name] = factory return factories @@ -244,8 +202,9 @@ def validate_operation_handler_methods( raise TypeError( f"Service '{user_service_cls}' does not implement operation '{op_name}' in interface '{service_definition}'. " ) - op = getattr(method, "__nexus_operation__", None) - if not isinstance(op, nexusrpc.Operation): + # TODO(prerelease): it should be guaranteed that `method` is a factory, so this next call should be unnecessary. + method, method_op_defn = get_operation_factory(method) + if not isinstance(method_op_defn, nexusrpc.Operation): raise RuntimeError( f"Method '{method}' in class '{user_service_cls.__name__}' " f"does not have a valid __nexus_operation__ attribute. " @@ -254,29 +213,29 @@ def validate_operation_handler_methods( ) # Input type is contravariant: op handler input must be superclass of op defn output if ( - op.input_type is not None + method_op_defn.input_type is not None and op_defn.input_type is not None - and Any not in (op.input_type, op_defn.input_type) + and Any not in (method_op_defn.input_type, op_defn.input_type) and not ( - op_defn.input_type == op.input_type - or issubclass(op_defn.input_type, op.input_type) + op_defn.input_type == method_op_defn.input_type + or issubclass(op_defn.input_type, method_op_defn.input_type) ) ): raise TypeError( - f"Operation '{op_name}' in service '{user_service_cls}' has input type '{op.input_type}', " + f"Operation '{op_name}' in service '{user_service_cls}' has input type '{method_op_defn.input_type}', " f"which is not compatible with the input type '{op_defn.input_type}' " f" in interface '{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 ( - op.output_type is not None + method_op_defn.output_type is not None and op_defn.output_type is not None - and Any not in (op.output_type, op_defn.output_type) - and not issubclass(op.output_type, op_defn.output_type) + and Any not in (method_op_defn.output_type, op_defn.output_type) + and not issubclass(method_op_defn.output_type, op_defn.output_type) ): raise TypeError( - f"Operation '{op_name}' in service '{user_service_cls}' has output type '{op.output_type}', " + f"Operation '{op_name}' in service '{user_service_cls}' has output type '{method_op_defn.output_type}', " f"which is not compatible with the output type '{op_defn.output_type}' in interface '{service_definition}'. " f"The output type must be the same as or a subclass of the operation definition output type." ) @@ -292,7 +251,7 @@ def validate_operation_handler_methods( ) -def service_from_operation_handler_methods( +def service_definition_from_operation_handler_methods( service_name: str, user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], ) -> nexusrpc.ServiceDefinition: @@ -304,16 +263,16 @@ def service_from_operation_handler_methods( :py:func:`@nexusrpc.handler.service_handler` decorator. This function is used when that is not the case. """ - operations: dict[str, nexusrpc.Operation[Any, Any]] = {} + op_defns: dict[str, nexusrpc.Operation[Any, Any]] = {} for name, method in user_methods.items(): - op = getattr(method, "__nexus_operation__", None) - if not isinstance(op, nexusrpc.Operation): + _, op_defn = get_operation_factory(method) + if not isinstance(op_defn, nexusrpc.Operation): raise RuntimeError( 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`?" ) - operations[op.name] = op + op_defns[op_defn.name] = op_defn - return nexusrpc.ServiceDefinition(name=service_name, operations=operations) + return nexusrpc.ServiceDefinition(name=service_name, operations=op_defns) diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 779cecc..1694915 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -4,19 +4,17 @@ import inspect import typing import warnings -from typing import ( - Any, - Awaitable, - Callable, - Optional, - Type, -) +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type from typing_extensions import TypeGuard +import nexusrpc from nexusrpc.handler._common import StartOperationContext from nexusrpc.types import InputT, OutputT, ServiceHandlerT +if TYPE_CHECKING: + from nexusrpc.handler._operation_handler import OperationHandler + def get_start_method_input_and_output_type_annotations( start: Callable[ @@ -62,6 +60,23 @@ def get_start_method_input_and_output_type_annotations( return input_type, output_type +def get_operation_factory( + obj: Any, +) -> tuple[ + Optional[Callable[[Any], OperationHandler[InputT, OutputT]]], + Optional[nexusrpc.Operation[InputT, OutputT]], +]: + op_defn = getattr(obj, "__nexus_operation__", None) + if op_defn: + factory = obj + else: + if factory := getattr(obj, "__nexus_operation_factory__", None): + op_defn = getattr(factory, "__nexus_operation__", None) + if not isinstance(op_defn, nexusrpc.Operation): + return None, None + return factory, op_defn + + # Copied from https://github.com/modelcontextprotocol/python-sdk # # Copyright (c) 2024 Anthropic, PBC. 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 index d8c6fa0..21d660e 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -213,14 +213,3 @@ async def test_collected_operation_definitions( 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 - - -def test_operation_without_decorator(): - class Service: - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... - - with pytest.warns( - UserWarning, - match=r"Did you forget to apply the @nexusrpc.handler.operation_handler decorator\?", - ): - nexusrpc.handler.service_handler(Service) 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 index d81cf43..7fffa6c 100644 --- 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 @@ -16,11 +16,11 @@ StartOperationContext, StartOperationResultAsync, StartOperationResultSync, - SyncOperationHandler, operation_handler, + service_handler, + sync_operation_handler, ) from nexusrpc.handler._core import collect_operation_handler_factories -from nexusrpc.handler._decorators import service_handler from nexusrpc.handler._util import is_async_callable from nexusrpc.types import InputT, OutputT @@ -63,14 +63,13 @@ def cancel(self, ctx: CancelOperationContext, token: str) -> None: class SyncOperation(_TestCase): @service_handler class Service: - @operation_handler - def sync_operation_handler(self) -> OperationHandler[int, int]: - async def start(ctx: StartOperationContext, input: int) -> int: - return 7 - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def sync_operation_handler( + self, ctx: StartOperationContext, input: int + ) -> int: + return 7 - expected_operation_factories = {"sync_operation_handler": 7} + expected_operation_factories = {"sync_operation_handler": 7} # type: ignore @pytest.mark.parametrize( 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 index 840c110..704adcc 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -4,11 +4,9 @@ import nexusrpc from nexusrpc.handler import ( - OperationHandler, StartOperationContext, - SyncOperationHandler, - operation_handler, service_handler, + sync_operation_handler, ) @@ -26,11 +24,8 @@ class Interface: def unrelated_method(self) -> None: ... class Impl: - @operation_handler - def op(self) -> OperationHandler[None, None]: - async def start(ctx: StartOperationContext, input: None) -> None: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: None) -> None: ... error_message = None @@ -41,11 +36,8 @@ class Interface: pass class Impl: - @operation_handler - def extra_op(self) -> OperationHandler[None, None]: - async def start(ctx: StartOperationContext, input: None) -> None: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def extra_op(self, ctx: StartOperationContext, input: None) -> None: ... def unrelated_method(self) -> None: ... @@ -58,11 +50,8 @@ class Interface: op: nexusrpc.Operation[int, str] class Impl: - @operation_handler - def op(self): - async def start(ctx, input): ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx, input): ... error_message = None @@ -84,11 +73,8 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @operation_handler - def op(self) -> OperationHandler[None, None]: - async def start(ctx: StartOperationContext, input) -> None: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input) -> None: ... error_message = None @@ -99,15 +85,13 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @operation_handler - def op(self) -> OperationHandler[str, None]: - async def start( - # TODO(prerelease) isn't this supposed to be missing the ctx annotation? - ctx: StartOperationContext, - input: str, - ) -> None: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op( + # TODO(prerelease) isn't this supposed to be missing the ctx annotation? + self, + ctx: StartOperationContext, + input: str, + ) -> None: ... error_message = "is not compatible with the input type" @@ -118,11 +102,8 @@ class Interface: op: nexusrpc.Operation[None, int] class Impl: - @operation_handler - def op(self) -> OperationHandler[None, str]: - async def start(ctx: StartOperationContext, input: None) -> str: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: None) -> str: ... error_message = "is not compatible with the output type" @@ -133,11 +114,8 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @operation_handler - def op(self) -> OperationHandler[str, str]: - async def start(ctx: StartOperationContext, input: str) -> str: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: str) -> str: ... error_message = "is not compatible with the output type" @@ -148,11 +126,8 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @operation_handler - def op(self) -> OperationHandler[str, None]: - async def start(ctx: StartOperationContext, input: str) -> None: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: str) -> None: ... error_message = None @@ -163,11 +138,8 @@ class Interface: op: nexusrpc.Operation[Any, Any] class Impl: - @operation_handler - def op(self) -> OperationHandler[str, str]: - async def start(ctx: StartOperationContext, input: str) -> str: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: str) -> str: ... error_message = None @@ -190,11 +162,8 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @operation_handler - def op(self) -> OperationHandler[X, X]: - async def start(ctx: StartOperationContext, input: X) -> X: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: X) -> X: ... error_message = None @@ -205,11 +174,8 @@ class Interface: op: nexusrpc.Operation[X, SuperClass] class Impl: - @operation_handler - def op(self) -> OperationHandler[X, Subclass]: - async def start(ctx: StartOperationContext, input: X) -> Subclass: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: X) -> Subclass: ... error_message = None @@ -222,11 +188,8 @@ class Interface: op: nexusrpc.Operation[X, Subclass] class Impl: - @operation_handler - def op(self) -> OperationHandler[X, SuperClass]: - async def start(ctx: StartOperationContext, input: X) -> SuperClass: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: X) -> SuperClass: ... error_message = "is not compatible with the output type" @@ -237,11 +200,8 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @operation_handler - def op(self) -> OperationHandler[X, X]: - async def start(ctx: StartOperationContext, input: X) -> X: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: X) -> X: ... error_message = None @@ -252,11 +212,8 @@ class Interface: op: nexusrpc.Operation[Subclass, X] class Impl: - @operation_handler - def op(self) -> OperationHandler[SuperClass, X]: - async def start(ctx: StartOperationContext, input: SuperClass) -> X: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: SuperClass) -> X: ... error_message = None @@ -267,11 +224,8 @@ class Interface: op: nexusrpc.Operation[SuperClass, X] class Impl: - @operation_handler - def op(self) -> OperationHandler[Subclass, X]: - async def start(ctx: StartOperationContext, input: Subclass) -> X: ... - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + async def op(self, ctx: StartOperationContext, input: Subclass) -> X: ... error_message = "is not compatible with the input type" @@ -318,8 +272,10 @@ class Contract: operation_a: nexusrpc.Operation[None, None] class Service: - @operation_handler - def operation_b(self) -> OperationHandler[None, None]: ... + @sync_operation_handler + async def operation_b( + self, ctx: StartOperationContext, input: None + ) -> None: ... with pytest.raises( TypeError, 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 index bbc6bae..9e0fead 100644 --- 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 @@ -3,15 +3,12 @@ import pytest from nexusrpc.handler import ( - OperationHandler, StartOperationContext, StartOperationResultSync, - SyncOperationHandler, - operation_handler, service_handler, + sync_operation_handler, ) -from nexusrpc.handler._util import is_async_callable -from nexusrpc.handler.syncio import SyncOperationHandler as SyncioSyncOperationHandler +from nexusrpc.handler._util import get_operation_factory, is_async_callable @service_handler @@ -19,32 +16,31 @@ class MyServiceHandler: def __init__(self): self.mutable_container = [] - @operation_handler - def my_def_op(self) -> OperationHandler[int, int]: - def start(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_handler + 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 - return SyncioSyncOperationHandler.from_callable(start) - - @operation_handler - def my_async_def_op(self) -> OperationHandler[int, int]: - async def start(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 - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + 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 +@pytest.mark.skip( + reason="TODO(prerelease): sync_operation_handler does not handle `def` yet" +) def test_def_sync_handler(): user_instance = MyServiceHandler() - op_handler = user_instance.my_def_op() + 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() @@ -61,7 +57,9 @@ def test_def_sync_handler(): @pytest.mark.asyncio async def test_async_def_sync_handler(): user_instance = MyServiceHandler() - op_handler = user_instance.my_async_def_op() + 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() diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index bffe90b..c846988 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -25,94 +25,85 @@ class Output: class _TestCase: - @staticmethod - def start(ctx: StartOperationContext, i: Input) -> Output: ... + async def start(self, ctx: StartOperationContext, i: Input) -> Output: ... expected_types: tuple[Any, Any] class SyncMethod(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i: Input) -> Output: ... + async def start(self, ctx: StartOperationContext, i: Input) -> Output: ... expected_types = (Input, Output) class AsyncMethod(_TestCase): - @staticmethod - async def start(ctx: StartOperationContext, i: Input) -> Output: ... + async def start(self, ctx: StartOperationContext, i: Input) -> Output: ... expected_types = (Input, Output) class UnionMethod(_TestCase): - @staticmethod - def start( - ctx: StartOperationContext, i: Input + async def start( + self, ctx: StartOperationContext, i: Input ) -> Union[Output, Awaitable[Output]]: ... expected_types = (Input, Union[Output, Awaitable[Output]]) class MissingInputAnnotationInUnionMethod(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i) -> Union[Output, Awaitable[Output]]: ... + async def start( + self, ctx: StartOperationContext, i + ) -> Union[Output, Awaitable[Output]]: ... expected_types = (None, Union[Output, Awaitable[Output]]) class TooFewParams(_TestCase): - @staticmethod - def start(i: Input) -> Output: ... + async def start(self, i: Input) -> Output: ... expected_types = (None, Output) class TooManyParams(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i: Input, extra: int) -> Output: ... + async def start( + self, ctx: StartOperationContext, i: Input, extra: int + ) -> Output: ... expected_types = (None, Output) class WrongOptionsType(_TestCase): - @staticmethod - def start(ctx: int, i: Input) -> Output: ... + async def start(self, ctx: int, i: Input) -> Output: ... expected_types = (None, Output) class NoReturnHint(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i: Input): ... + async def start(self, ctx: StartOperationContext, i: Input): ... expected_types = (Input, None) class NoInputAnnotation(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i) -> Output: ... + async def start(self, ctx: StartOperationContext, i) -> Output: ... expected_types = (None, Output) class NoOptionsAnnotation(_TestCase): - @staticmethod - def start(ctx, i: Input) -> Output: ... + async def start(self, ctx, i: Input) -> Output: ... expected_types = (None, Output) class AllAnnotationsMissing(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i): ... + async def start(self, ctx: StartOperationContext, i): ... expected_types = (None, None) class ExplicitNoneTypes(_TestCase): - @staticmethod - def start(ctx: StartOperationContext, i: None) -> None: ... + async def start(self, ctx: StartOperationContext, i: None) -> None: ... expected_types = (type(None), type(None)) From ef3aeb9e443bb87feb542a9824b7bd164c4fdca1 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 25 Jun 2025 17:10:40 -0400 Subject: [PATCH 077/178] Support name override, add overloads --- src/nexusrpc/handler/_decorators.py | 39 +++++++++++++++++-- ...corator_creates_valid_operation_handler.py | 2 +- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index a5ac812..81f063e 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -203,11 +203,42 @@ def decorator( return decorator(method) +# TODO(prerelease) `def` support +@overload def sync_operation_handler( start: Callable[ [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] ], -) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: +) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: ... + + +@overload +def sync_operation_handler( + *, + name: Optional[str] = None, +) -> Callable[ + [Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]], + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], +]: ... + + +def sync_operation_handler( + 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. """ @@ -231,8 +262,7 @@ async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: ) operation_handler_factory.__nexus_operation__ = nexusrpc.Operation( - # TODO(prerelease): extend decorator to support name override param - name=start.__name__, + name=name or start.__name__, method_name=start.__name__, input_type=input_type, output_type=output_type, @@ -241,4 +271,7 @@ async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: start.__nexus_operation_factory__ = operation_handler_factory return start + if start is None: + return decorator + return decorator(start) 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 index 9e0fead..07350e2 100644 --- 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 @@ -24,7 +24,7 @@ def my_def_op(self, ctx: StartOperationContext, input: int) -> int: self.mutable_container.append(input) return input + 1 - @sync_operation_handler + @sync_operation_handler(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. From 41f10084c928dddd7a5bde6682a6ef874cbe45b4 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 25 Jun 2025 21:52:43 -0400 Subject: [PATCH 078/178] Make sync_operation_handler support sync start methods --- src/nexusrpc/_serializer.py | 2 - src/nexusrpc/handler/_decorators.py | 73 ++++++++++--- src/nexusrpc/handler/_operation_handler.py | 59 ++++++++-- src/nexusrpc/handler/_util.py | 5 +- src/nexusrpc/handler/syncio.py | 101 ------------------ tests/handler/test_handler_syncio.py | 13 +-- ...corator_creates_valid_operation_handler.py | 3 - 7 files changed, 117 insertions(+), 139 deletions(-) delete mode 100644 src/nexusrpc/handler/syncio.py diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index 0355f82..38f87c7 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -102,8 +102,6 @@ async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: as_type=as_type, ) - # TODO(prerelease): we have a syncio module now for the syncio version of SyncOperationHandler - # SHould this go in a syncio module? def consume_sync(self, as_type: Optional[Type[Any]] = None) -> Any: """ Consume the underlying reader stream, deserializing via the embedded serializer. diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 81f063e..e84e07e 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -10,16 +10,21 @@ Type, TypeVar, Union, + cast, overload, ) import nexusrpc from nexusrpc.handler._common import StartOperationContext -from nexusrpc.handler._util import get_start_method_input_and_output_type_annotations +from nexusrpc.handler._util import ( + get_start_method_input_and_output_type_annotations, + is_async_callable, +) from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._operation_handler import ( OperationHandler, + SyncioSyncOperationHandler, SyncOperationHandler, collect_operation_handler_factories, service_definition_from_operation_handler_methods, @@ -203,13 +208,16 @@ def decorator( return decorator(method) -# TODO(prerelease) `def` support @overload def sync_operation_handler( start: Callable[ - [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], ], -) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: ... +) -> Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], +]: ... @overload @@ -217,26 +225,44 @@ def sync_operation_handler( *, name: Optional[str] = None, ) -> Callable[ - [Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]], - Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], + [ + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ] + ], + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ], ]: ... def sync_operation_handler( start: Optional[ - Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]] + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ] ] = None, *, name: Optional[str] = None, ) -> Union[ - Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ], Callable[ [ Callable[ - [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], ] ], - Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], + Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ], ], ]: """ @@ -245,17 +271,32 @@ def sync_operation_handler( def decorator( start: Callable[ - [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], ], - ) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: + ) -> Callable[ + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], + ]: def operation_handler_factory( self: ServiceHandlerT, ) -> OperationHandler[InputT, OutputT]: - async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: - return await start(self, ctx, input) + if is_async_callable(start): + start_async = start + + async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: + return await start_async(self, ctx, input) + + _start.__doc__ = start.__doc__ + return SyncOperationHandler(_start) + else: + start_sync = cast(Callable[..., OutputT], start) + + def _start_sync(ctx: StartOperationContext, input: InputT) -> OutputT: + return start_sync(self, ctx, input) - _start.__doc__ = start.__doc__ - return SyncOperationHandler(_start) + _start_sync.__doc__ = start.__doc__ + return SyncioSyncOperationHandler(_start_sync) input_type, output_type = get_start_method_input_and_output_type_annotations( start diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 01c8706..c85226d 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -100,7 +100,7 @@ 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`. + :py:class:`SyncioSyncOperationHandler`. """ def __init__( @@ -132,21 +132,67 @@ async def start( async def fetch_info( self, ctx: FetchOperationInfoContext, token: str - ) -> Union[OperationInfo, Awaitable[OperationInfo]]: + ) -> OperationInfo: raise NotImplementedError( "Cannot fetch operation info for an operation that responded synchronously." ) async def fetch_result( self, ctx: FetchOperationResultContext, token: str - ) -> Union[OutputT, Awaitable[OutputT]]: + ) -> OutputT: raise NotImplementedError( "Cannot fetch the result of an operation that responded synchronously." ) - async def cancel( - self, ctx: CancelOperationContext, token: str - ) -> Union[None, Awaitable[None]]: + async def cancel(self, ctx: CancelOperationContext, token: str) -> None: + raise NotImplementedError( + "An operation that responded synchronously cannot be cancelled." + ) + + +class SyncioSyncOperationHandler(OperationHandler[InputT, OutputT]): + """ + An :py:class:`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:`SyncOperationHandler`. + """ + + def __init__(self, start: Callable[[StartOperationContext, InputT], OutputT]): + if is_async_callable(start): + raise RuntimeError( + f"{start} is an `async def` method. " + "SyncioSyncOperationHandler must be initialized with a `def` method. " + "To use `async def` methods, use SyncOperationHandler." + ) + self._start = start + if start.__doc__: + self.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." ) @@ -227,6 +273,7 @@ def validate_operation_handler_methods( f" in interface '{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 diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 1694915..744afe5 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -4,7 +4,7 @@ import inspect import typing import warnings -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type, Union from typing_extensions import TypeGuard @@ -18,7 +18,8 @@ def get_start_method_input_and_output_type_annotations( start: Callable[ - [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] + [ServiceHandlerT, StartOperationContext, InputT], + Union[OutputT, Awaitable[OutputT]], ], ) -> tuple[ Optional[Type[InputT]], diff --git a/src/nexusrpc/handler/syncio.py b/src/nexusrpc/handler/syncio.py deleted file mode 100644 index a837ead..0000000 --- a/src/nexusrpc/handler/syncio.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import ( - Awaitable, - Callable, - Optional, - Union, -) - -from nexusrpc import OperationInfo -from nexusrpc.handler import ( - CancelOperationContext, - FetchOperationInfoContext, - FetchOperationResultContext, - OperationHandler, - StartOperationContext, - StartOperationResultSync, -) -from nexusrpc.handler._util import is_async_callable -from nexusrpc.types import InputT, OutputT - - -class SyncOperationHandler(OperationHandler[InputT, OutputT], ABC): - """ - An :py:class:`OperationHandler` that is limited to responding synchronously. - - This version of the class uses traditional `def` methods, instead of `async def`. - For the async version, see :py:class:`nexusrpc.handler.SyncOperationHandler`. - """ - - def __init__( - self, - start: Optional[Callable[[StartOperationContext, InputT], OutputT]] = None, - ): - if start is not None: - if is_async_callable(start): - raise RuntimeError( - f"{start} is an `async def` method. " - "syncio.SyncOperationHandler must be initialized with a `def` method. " - "To use `async def` methods, see :py:class:`nexusrpc.handler.SyncOperationHandler`." - ) - self._start = start - if start.__doc__: - self.start.__func__.__doc__ = start.__doc__ - else: - self._start = None - - @classmethod - def from_callable( - cls, - start: Callable[[StartOperationContext, InputT], OutputT], - ) -> SyncOperationHandler[InputT, OutputT]: - return _SyncOperationHandler(start) - - @abstractmethod - def start( - self, ctx: StartOperationContext, input: InputT - ) -> StartOperationResultSync[OutputT]: - """ - 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 def` - version, see :py:class:`nexusrpc.handler.SyncOperationHandler`. - """ - ... - - def fetch_info( - self, ctx: FetchOperationInfoContext, token: str - ) -> Union[OperationInfo, Awaitable[OperationInfo]]: - raise NotImplementedError( - "Cannot fetch operation info for an operation that responded synchronously." - ) - - def fetch_result( - self, ctx: FetchOperationResultContext, token: str - ) -> Union[OutputT, Awaitable[OutputT]]: - raise NotImplementedError( - "Cannot fetch the result of an operation that responded synchronously." - ) - - def cancel( - self, ctx: CancelOperationContext, token: str - ) -> Union[None, Awaitable[None]]: - raise NotImplementedError( - "An operation that responded synchronously cannot be cancelled." - ) - - -class _SyncOperationHandler(SyncOperationHandler[InputT, OutputT]): - def start( - self, ctx: StartOperationContext, input: InputT - ) -> StartOperationResultSync[OutputT]: - if self._start is None: - raise RuntimeError( - "Do not use _SyncOperationHandler directly. " - "Use SyncOperationHandler.from_callable instead." - ) - output = self._start(ctx, input) - return StartOperationResultSync(output) diff --git a/tests/handler/test_handler_syncio.py b/tests/handler/test_handler_syncio.py index ad3220d..77919b6 100644 --- a/tests/handler/test_handler_syncio.py +++ b/tests/handler/test_handler_syncio.py @@ -6,14 +6,12 @@ from nexusrpc import Content, LazyValue from nexusrpc.handler import ( - OperationHandler, StartOperationContext, StartOperationResultSync, SyncioHandler, - operation_handler, service_handler, + sync_operation_handler, ) -from nexusrpc.handler.syncio import SyncOperationHandler class _TestCase: @@ -23,12 +21,9 @@ class _TestCase: class SyncHandlerHappyPath: @service_handler class MyService: - @operation_handler - def incr(self) -> OperationHandler[int, int]: - def start(ctx: StartOperationContext, input: int) -> int: - return input + 1 - - return SyncOperationHandler.from_callable(start) + @sync_operation_handler + def incr(self, ctx: StartOperationContext, input: int) -> int: + return input + 1 user_service_handler = MyService() 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 index 07350e2..741bd60 100644 --- 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 @@ -33,9 +33,6 @@ async def my_async_def_op(self, ctx: StartOperationContext, input: int) -> int: return input + 2 -@pytest.mark.skip( - reason="TODO(prerelease): sync_operation_handler does not handle `def` yet" -) def test_def_sync_handler(): user_instance = MyServiceHandler() op_handler_factory, _ = get_operation_factory(user_instance.my_def_op) From 92a4a14c4bf3b217008e0e208eb4b0128afdeb3f Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 25 Jun 2025 22:12:39 -0400 Subject: [PATCH 079/178] Fix callable instances --- src/nexusrpc/handler/_decorators.py | 6 ++-- src/nexusrpc/handler/_util.py | 12 +++++++ ...test_service_handler_from_user_instance.py | 35 ++++++++----------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index e84e07e..4d66d45 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -17,6 +17,7 @@ import nexusrpc from nexusrpc.handler._common import StartOperationContext from nexusrpc.handler._util import ( + get_callable_name, get_start_method_input_and_output_type_annotations, is_async_callable, ) @@ -302,9 +303,10 @@ def _start_sync(ctx: StartOperationContext, input: InputT) -> OutputT: start ) + method_name = get_callable_name(start) operation_handler_factory.__nexus_operation__ = nexusrpc.Operation( - name=name or start.__name__, - method_name=start.__name__, + name=name or method_name, + method_name=method_name, input_type=input_type, output_type=output_type, ) diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 744afe5..5ca3c0f 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -78,6 +78,18 @@ def get_operation_factory( return factory, op_defn +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 + + # Copied from https://github.com/modelcontextprotocol/python-sdk # # Copyright (c) 2024 Anthropic, PBC. diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py index 488b2a5..371521c 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,34 +1,29 @@ from __future__ import annotations -import pytest - import nexusrpc.handler from nexusrpc.handler._core import ServiceHandler # TODO(preview): test operation_handler version of this -if False: - - @nexusrpc.handler.service_handler - class MyServiceHandlerWithCallableInstance: - class SyncOperationWithCallableInstance: - def __call__( - self, - _handler: MyServiceHandlerWithCallableInstance, - ctx: nexusrpc.handler.StartOperationContext, - input: int, - ) -> int: - return input +@nexusrpc.handler.service_handler +class MyServiceHandlerWithCallableInstance: + class SyncOperationWithCallableInstance: + def __call__( + self, + _handler: MyServiceHandlerWithCallableInstance, + ctx: nexusrpc.handler.StartOperationContext, + input: int, + ) -> int: + return input - sync_operation_with_callable_instance = nexusrpc.handler.operation_handler( - name="sync_operation_with_callable_instance", - )( - SyncOperationWithCallableInstance(), # type: ignore - ) + sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( + name="sync_operation_with_callable_instance", + )( + SyncOperationWithCallableInstance(), + ) -@pytest.mark.skip(reason="TODO(prerelease): update this test after decorator change") def test_service_handler_from_user_instance(): service_handler = MyServiceHandlerWithCallableInstance() # type: ignore ServiceHandler.from_user_instance(service_handler) From cb5774beb332582ab39606b5a465381ce9acc106 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 06:47:16 -0400 Subject: [PATCH 080/178] rename: @sync_operation --- src/nexusrpc/handler/__init__.py | 2 +- src/nexusrpc/handler/_decorators.py | 6 ++-- tests/handler/test_handler_syncio.py | 4 +-- ...rrectly_functioning_operation_factories.py | 4 +-- ...ator_validates_against_service_contract.py | 34 +++++++++---------- ...test_service_handler_from_user_instance.py | 2 +- ...corator_creates_valid_operation_handler.py | 6 ++-- 7 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 698553e..d0a83fb 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -30,7 +30,7 @@ from ._decorators import ( operation_handler as operation_handler, service_handler as service_handler, - sync_operation_handler as sync_operation_handler, + sync_operation as sync_operation, ) from ._operation_handler import OperationHandler as OperationHandler from ._util import ( diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 4d66d45..1abd667 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -210,7 +210,7 @@ def decorator( @overload -def sync_operation_handler( +def sync_operation( start: Callable[ [ServiceHandlerT, StartOperationContext, InputT], Union[OutputT, Awaitable[OutputT]], @@ -222,7 +222,7 @@ def sync_operation_handler( @overload -def sync_operation_handler( +def sync_operation( *, name: Optional[str] = None, ) -> Callable[ @@ -239,7 +239,7 @@ def sync_operation_handler( ]: ... -def sync_operation_handler( +def sync_operation( start: Optional[ Callable[ [ServiceHandlerT, StartOperationContext, InputT], diff --git a/tests/handler/test_handler_syncio.py b/tests/handler/test_handler_syncio.py index 77919b6..255f845 100644 --- a/tests/handler/test_handler_syncio.py +++ b/tests/handler/test_handler_syncio.py @@ -10,7 +10,7 @@ StartOperationResultSync, SyncioHandler, service_handler, - sync_operation_handler, + sync_operation, ) @@ -21,7 +21,7 @@ class _TestCase: class SyncHandlerHappyPath: @service_handler class MyService: - @sync_operation_handler + @sync_operation def incr(self, ctx: StartOperationContext, input: int) -> int: return input + 1 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 index 7fffa6c..7047f89 100644 --- 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 @@ -18,7 +18,7 @@ StartOperationResultSync, operation_handler, service_handler, - sync_operation_handler, + sync_operation, ) from nexusrpc.handler._core import collect_operation_handler_factories from nexusrpc.handler._util import is_async_callable @@ -63,7 +63,7 @@ def cancel(self, ctx: CancelOperationContext, token: str) -> None: class SyncOperation(_TestCase): @service_handler class Service: - @sync_operation_handler + @sync_operation async def sync_operation_handler( self, ctx: StartOperationContext, input: int ) -> int: 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 index 704adcc..3a27e4c 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -6,7 +6,7 @@ from nexusrpc.handler import ( StartOperationContext, service_handler, - sync_operation_handler, + sync_operation, ) @@ -24,7 +24,7 @@ class Interface: def unrelated_method(self) -> None: ... class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> None: ... error_message = None @@ -36,7 +36,7 @@ class Interface: pass class Impl: - @sync_operation_handler + @sync_operation async def extra_op(self, ctx: StartOperationContext, input: None) -> None: ... def unrelated_method(self) -> None: ... @@ -50,7 +50,7 @@ class Interface: op: nexusrpc.Operation[int, str] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx, input): ... error_message = None @@ -73,7 +73,7 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input) -> None: ... error_message = None @@ -85,7 +85,7 @@ class Interface: op: nexusrpc.Operation[None, None] class Impl: - @sync_operation_handler + @sync_operation async def op( # TODO(prerelease) isn't this supposed to be missing the ctx annotation? self, @@ -102,7 +102,7 @@ class Interface: op: nexusrpc.Operation[None, int] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> str: ... error_message = "is not compatible with the output type" @@ -114,7 +114,7 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: str) -> str: ... error_message = "is not compatible with the output type" @@ -126,7 +126,7 @@ class Interface: op: nexusrpc.Operation[str, None] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: str) -> None: ... error_message = None @@ -138,7 +138,7 @@ class Interface: op: nexusrpc.Operation[Any, Any] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: str) -> str: ... error_message = None @@ -162,7 +162,7 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: X) -> X: ... error_message = None @@ -174,7 +174,7 @@ class Interface: op: nexusrpc.Operation[X, SuperClass] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: X) -> Subclass: ... error_message = None @@ -188,7 +188,7 @@ class Interface: op: nexusrpc.Operation[X, Subclass] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: X) -> SuperClass: ... error_message = "is not compatible with the output type" @@ -200,7 +200,7 @@ class Interface: op: nexusrpc.Operation[X, X] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: X) -> X: ... error_message = None @@ -212,7 +212,7 @@ class Interface: op: nexusrpc.Operation[Subclass, X] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: SuperClass) -> X: ... error_message = None @@ -224,7 +224,7 @@ class Interface: op: nexusrpc.Operation[SuperClass, X] class Impl: - @sync_operation_handler + @sync_operation async def op(self, ctx: StartOperationContext, input: Subclass) -> X: ... error_message = "is not compatible with the input type" @@ -272,7 +272,7 @@ class Contract: operation_a: nexusrpc.Operation[None, None] class Service: - @sync_operation_handler + @sync_operation async def operation_b( self, ctx: StartOperationContext, input: None ) -> None: ... diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py index 371521c..410726a 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -17,7 +17,7 @@ def __call__( ) -> int: return input - sync_operation_with_callable_instance = nexusrpc.handler.sync_operation_handler( + sync_operation_with_callable_instance = nexusrpc.handler.sync_operation( name="sync_operation_with_callable_instance", )( SyncOperationWithCallableInstance(), 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 index 741bd60..8da4dca 100644 --- 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 @@ -6,7 +6,7 @@ StartOperationContext, StartOperationResultSync, service_handler, - sync_operation_handler, + sync_operation, ) from nexusrpc.handler._util import get_operation_factory, is_async_callable @@ -16,7 +16,7 @@ class MyServiceHandler: def __init__(self): self.mutable_container = [] - @sync_operation_handler + @sync_operation def my_def_op(self, ctx: StartOperationContext, input: int) -> int: """ This is the docstring for the `my_def_op` sync operation. @@ -24,7 +24,7 @@ def my_def_op(self, ctx: StartOperationContext, input: int) -> int: self.mutable_container.append(input) return input + 1 - @sync_operation_handler(name="foo") + @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. From 4bfd25d9a5b2b0139f3fb7941e61e745e28bc7e4 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 07:16:55 -0400 Subject: [PATCH 081/178] Clean up imports in tests --- tests/handler/test_async_operation.py | 2 +- ...er_validates_service_handler_collection.py | 38 +++++++++----- ...collects_expected_operation_definitions.py | 51 ++++++++++--------- ..._service_handler_decorator_requirements.py | 49 +++++++++--------- ...rrectly_functioning_operation_factories.py | 2 +- ...tor_validates_duplicate_operation_names.py | 16 +++--- ...test_service_handler_from_user_instance.py | 14 +++-- 7 files changed, 98 insertions(+), 74 deletions(-) diff --git a/tests/handler/test_async_operation.py b/tests/handler/test_async_operation.py index 080e641..22f5c6c 100644 --- a/tests/handler/test_async_operation.py +++ b/tests/handler/test_async_operation.py @@ -13,9 +13,9 @@ OperationHandler, StartOperationContext, StartOperationResultAsync, - operation_handler, service_handler, ) +from nexusrpc.handler._decorators import operation_handler class _TestCase: diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index f9e992c..a16a6c7 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -5,8 +5,18 @@ import pytest -import nexusrpc.handler -from nexusrpc.handler import Handler +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(): @@ -18,35 +28,35 @@ class Service: def test_services_are_collected(): - class OpHandler(nexusrpc.handler.OperationHandler[int, int]): + class OpHandler(OperationHandler[int, int]): async def start( self, - ctx: nexusrpc.handler.StartOperationContext, + ctx: StartOperationContext, input: int, - ) -> nexusrpc.handler.StartOperationResultSync[int]: ... + ) -> StartOperationResultSync[int]: ... async def cancel( self, - ctx: nexusrpc.handler.CancelOperationContext, + ctx: CancelOperationContext, token: str, ) -> None: ... async def fetch_info( self, - ctx: nexusrpc.handler.FetchOperationInfoContext, + ctx: FetchOperationInfoContext, token: str, - ) -> nexusrpc.OperationInfo: ... + ) -> OperationInfo: ... async def fetch_result( self, - ctx: nexusrpc.handler.FetchOperationResultContext, + ctx: FetchOperationResultContext, token: str, ) -> int: ... - @nexusrpc.handler.service_handler + @service_handler class Service1: - @nexusrpc.handler.operation_handler - def op(self) -> nexusrpc.handler.OperationHandler[int, int]: + @operation_handler + def op(self) -> OperationHandler[int, int]: return OpHandler() service_handlers = Handler([Service1()]) @@ -58,11 +68,11 @@ def op(self) -> nexusrpc.handler.OperationHandler[int, int]: def test_service_names_must_be_unique(): - @nexusrpc.handler.service_handler(name="a") + @service_handler(name="a") class Service1: pass - @nexusrpc.handler.service_handler(name="a") + @service_handler(name="a") class Service2: pass 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 index 21d660e..971ca0c 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -8,8 +8,13 @@ import pytest -import nexusrpc._service -import nexusrpc.handler +import nexusrpc +from nexusrpc.handler import ( + OperationHandler, + StartOperationContext, + service_handler, +) +from nexusrpc.handler._decorators import operation_handler @dataclass @@ -31,10 +36,10 @@ class _TestCase: class ManualOperationHandler(_TestCase): - @nexusrpc.handler.service_handler + @service_handler class Service: - @nexusrpc.handler.operation_handler - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + @operation_handler + def operation(self) -> OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -47,10 +52,10 @@ def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... class ManualOperationHandlerWithNameOverride(_TestCase): - @nexusrpc.handler.service_handler + @service_handler class Service: - @nexusrpc.handler.operation_handler(name="operation-name") - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + @operation_handler(name="operation-name") + def operation(self) -> OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -63,12 +68,12 @@ def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... class SyncOperation(_TestCase): - @nexusrpc.handler.service_handler + @service_handler class Service: - @nexusrpc.handler.operation_handler + @operation_handler def sync_operation_handler( self, - ) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + ) -> OperationHandler[Input, Output]: ... expected_operations = { "sync_operation_handler": nexusrpc.Operation( @@ -81,12 +86,12 @@ def sync_operation_handler( class SyncOperationWithOperationHandlerNameOverride(_TestCase): - @nexusrpc.handler.service_handler + @service_handler class Service: - @nexusrpc.handler.operation_handler(name="sync-operation-name") + @operation_handler(name="sync-operation-name") def sync_operation_handler( self, - ) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + ) -> OperationHandler[Input, Output]: ... expected_operations = { "sync_operation_handler": nexusrpc.Operation( @@ -103,10 +108,10 @@ class ManualOperationWithContract(_TestCase): class Contract: operation: nexusrpc.Operation[Input, Output] - @nexusrpc.handler.service_handler(service=Contract) + @service_handler(service=Contract) class Service: - @nexusrpc.handler.operation_handler - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + @operation_handler + def operation(self) -> OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -125,10 +130,10 @@ class Contract: name="operation-override", ) - @nexusrpc.handler.service_handler(service=Contract) + @service_handler(service=Contract) class Service: - @nexusrpc.handler.operation_handler(name="operation-override") - def operation(self) -> nexusrpc.handler.OperationHandler[Input, Output]: ... + @operation_handler(name="operation-override") + def operation(self) -> OperationHandler[Input, Output]: ... expected_operations = { "operation": nexusrpc.Operation( @@ -149,20 +154,20 @@ class SyncOperationWithCallableInstance(_TestCase): class Contract: sync_operation_with_callable_instance: nexusrpc.Operation[Input, Output] - @nexusrpc.handler.service_handler(service=Contract) + @service_handler(service=Contract) class Service: class sync_operation_with_callable_instance: def __call__( self, _handler: Any, - ctx: nexusrpc.handler.StartOperationContext, + ctx: StartOperationContext, input: Input, ) -> Output: ... # TODO(preview): improve the DX here. The decorator cannot be placed on the # callable class itself, because the user must be responsible for instantiating # the class to obtain the callable instance. - sync_operation_with_callable_instance = nexusrpc.handler.operation_handler( + sync_operation_with_callable_instance = operation_handler( sync_operation_with_callable_instance() # type: ignore ) diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index fb70ae1..8e5ea70 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -5,9 +5,12 @@ import pytest import nexusrpc -import nexusrpc._service -import nexusrpc.handler +from nexusrpc.handler import ( + OperationHandler, + service_handler, +) from nexusrpc.handler._core import ServiceHandler +from nexusrpc.handler._decorators import operation_handler # TODO(prerelease): check return type of op methods including fetch_result and fetch_info # temporalio.common._type_hints_from_func(hello_nexus.hello2().fetch_result), @@ -26,8 +29,8 @@ class UserService: op_B: nexusrpc.Operation[bool, float] class UserServiceHandler: - @nexusrpc.handler.operation_handler - def op_A(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + @operation_handler + def op_A(self) -> OperationHandler[int, str]: ... expected_error_message_pattern = r"does not implement operation 'op_B'" @@ -38,10 +41,10 @@ class UserService: op_A: nexusrpc.Operation[int, str] = nexusrpc.Operation(name="foo") class UserServiceHandler: - @nexusrpc.handler.operation_handler + @operation_handler def op_A_incorrect_method_name( self, - ) -> nexusrpc.handler.OperationHandler[int, str]: ... + ) -> OperationHandler[int, str]: ... expected_error_message_pattern = ( r"does not match an operation method name in the service definition." @@ -59,9 +62,7 @@ def test_decorator_validates_definition_compliance( test_case: _DecoratorValidationTestCase, ): with pytest.raises(TypeError, match=test_case.expected_error_message_pattern): - nexusrpc.handler.service_handler(service=test_case.UserService)( - test_case.UserServiceHandler - ) + service_handler(service=test_case.UserService)(test_case.UserServiceHandler) class _ServiceHandlerInheritanceTestCase: @@ -81,29 +82,29 @@ class UserService: base_op: nexusrpc.Operation[int, str] child_op: nexusrpc.Operation[bool, float] - @nexusrpc.handler.service_handler(service=BaseUserService) + @service_handler(service=BaseUserService) class BaseUserServiceHandler: - @nexusrpc.handler.operation_handler - def base_op(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + @operation_handler + def base_op(self) -> OperationHandler[int, str]: ... - @nexusrpc.handler.service_handler(service=UserService) + @service_handler(service=UserService) class UserServiceHandler(BaseUserServiceHandler): - @nexusrpc.handler.operation_handler - def child_op(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... + @operation_handler + def child_op(self) -> OperationHandler[bool, float]: ... expected_operations = {"base_op", "child_op"} class ServiceHandlerInheritanceWithoutDefinition(_ServiceHandlerInheritanceTestCase): - @nexusrpc.handler.service_handler + @service_handler class BaseUserServiceHandler: - @nexusrpc.handler.operation_handler - def base_op_nc(self) -> nexusrpc.handler.OperationHandler[int, str]: ... + @operation_handler + def base_op_nc(self) -> OperationHandler[int, str]: ... - @nexusrpc.handler.service_handler + @service_handler class UserServiceHandler(BaseUserServiceHandler): - @nexusrpc.handler.operation_handler - def child_op_nc(self) -> nexusrpc.handler.OperationHandler[bool, float]: ... + @operation_handler + def child_op_nc(self) -> OperationHandler[bool, float]: ... expected_operations = {"base_op_nc", "child_op_nc"} @@ -168,9 +169,9 @@ def test_service_definition_inheritance_behavior( TypeError, match="does not implement operation 'op_from_child_definition'" ): - @nexusrpc.handler.service_handler(service=test_case.UserService) + @service_handler(service=test_case.UserService) class HandlerMissingChildOp: - @nexusrpc.handler.operation_handler + @operation_handler def op_from_base_definition( self, - ) -> nexusrpc.handler.OperationHandler[int, str]: ... + ) -> 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 index 7047f89..baf2f04 100644 --- 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 @@ -16,11 +16,11 @@ StartOperationContext, StartOperationResultAsync, StartOperationResultSync, - operation_handler, service_handler, sync_operation, ) from nexusrpc.handler._core import collect_operation_handler_factories +from nexusrpc.handler._decorators import operation_handler from nexusrpc.handler._util import is_async_callable from nexusrpc.types import InputT, OutputT 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 index 9936167..195115a 100644 --- a/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py +++ b/tests/handler/test_service_handler_decorator_validates_duplicate_operation_names.py @@ -2,7 +2,11 @@ import pytest -import nexusrpc.handler +from nexusrpc.handler import ( + OperationHandler, + service_handler, +) +from nexusrpc.handler._decorators import operation_handler class _TestCase: @@ -12,11 +16,11 @@ class _TestCase: class DuplicateOperationName(_TestCase): class UserServiceHandler: - @nexusrpc.handler.operation_handler(name="a") - def op_1(self) -> nexusrpc.handler.OperationHandler[int, int]: ... + @operation_handler(name="a") + def op_1(self) -> OperationHandler[int, int]: ... - @nexusrpc.handler.operation_handler(name="a") - def op_2(self) -> nexusrpc.handler.OperationHandler[str, int]: ... + @operation_handler(name="a") + def op_2(self) -> OperationHandler[str, int]: ... expected_error_message = ( "Operation 'a' in service 'UserServiceHandler' is defined multiple times." @@ -31,4 +35,4 @@ def op_2(self) -> nexusrpc.handler.OperationHandler[str, int]: ... ) def test_service_handler_decorator(test_case: _TestCase): with pytest.raises(RuntimeError, match=test_case.expected_error_message): - nexusrpc.handler.service_handler(test_case.UserServiceHandler) + 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 index 410726a..1219f29 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,23 +1,27 @@ from __future__ import annotations -import nexusrpc.handler +from nexusrpc.handler import ( + StartOperationContext, + service_handler, + sync_operation, +) from nexusrpc.handler._core import ServiceHandler # TODO(preview): test operation_handler version of this -@nexusrpc.handler.service_handler +@service_handler class MyServiceHandlerWithCallableInstance: class SyncOperationWithCallableInstance: def __call__( self, _handler: MyServiceHandlerWithCallableInstance, - ctx: nexusrpc.handler.StartOperationContext, + ctx: StartOperationContext, input: int, ) -> int: return input - sync_operation_with_callable_instance = nexusrpc.handler.sync_operation( + sync_operation_with_callable_instance = sync_operation( name="sync_operation_with_callable_instance", )( SyncOperationWithCallableInstance(), @@ -25,5 +29,5 @@ def __call__( def test_service_handler_from_user_instance(): - service_handler = MyServiceHandlerWithCallableInstance() # type: ignore + service_handler = MyServiceHandlerWithCallableInstance() ServiceHandler.from_user_instance(service_handler) From 68fcac8276a8a608bca37afa875e0738b54a58b1 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 07:17:06 -0400 Subject: [PATCH 082/178] Remove @operation_handler from public API for now --- src/nexusrpc/handler/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index d0a83fb..d9b79f6 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -28,7 +28,6 @@ SyncioHandler as SyncioHandler, ) from ._decorators import ( - operation_handler as operation_handler, service_handler as service_handler, sync_operation as sync_operation, ) From 4a2d19ab78e40ed9e9819c2bdc56e2e6657cc953 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 09:51:48 -0400 Subject: [PATCH 083/178] CI test on 3.9, 3.13, 3.14 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6762174..6fc88de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.9', '3.13', '3.14'] steps: - name: Checkout repository From ad9791f1a7b155d249e917da13e23fa613d7106a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 09:53:34 -0400 Subject: [PATCH 084/178] Activate vercel publishing of apidocs --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fc88de..c20bd3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,10 +50,10 @@ jobs: - name: Build API docs run: uv run pydoctor src/nexusrpc - # TODO(prerelease) - # - name: Deploy prod API docs - # if: ${{ github.ref == 'refs/heads/main' }} - # env: - # VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - # VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - # run: npx vercel deploy build/apidocs -t ${{ secrets.VERCEL_TOKEN }} --prod --yes \ No newline at end of file + + - name: Deploy prod API docs + if: ${{ github.ref == 'refs/heads/main' }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + run: npx vercel deploy build/apidocs -t ${{ secrets.VERCEL_TOKEN }} --prod --yes From be8fba2f9c88aefaa14f9641eb53909ba685b40b Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 10:27:46 -0400 Subject: [PATCH 085/178] Start adding README content --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3b82630..84d759e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ ## What is Nexus? -Nexus is a synchronous RPC protocol. Arbitrary duration operations are modelled on top -of a set of pre-defined synchronous RPCs. +[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 @@ -11,4 +11,64 @@ cancel an asynchronous operation, check for its outcome, or fetch its current st caller can also specify a callback URL, which the handler uses to deliver the result of an asynchronous operation when it is ready. -TODO(prerelease): README content \ No newline at end of file +## 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}!") +``` + From f4a2f4233613b85135aaa0576ec00a5187f1e975 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 10:34:36 -0400 Subject: [PATCH 086/178] uv remove --group dev httpx --- pyproject.toml | 1 - uv.lock | 63 -------------------------------------------------- 2 files changed, 64 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4eeb31..e7cff74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ [dependency-groups] dev = [ - "httpx>=0.28.1", "mypy>=1.15.0", "pydoctor>=25.4.0", "pyright>=1.1.400", diff --git a/uv.lock b/uv.lock index eb9d2bf..1ff54f7 100644 --- a/uv.lock +++ b/uv.lock @@ -7,21 +7,6 @@ resolution-markers = [ "python_full_version < '3.10'", ] -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, -] - [[package]] name = "attrs" version = "25.3.0" @@ -277,43 +262,6 @@ 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 = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - [[package]] name = "hyperlink" version = "21.0.0" @@ -513,7 +461,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, { name = "mypy" }, { name = "pydoctor" }, { name = "pyright" }, @@ -529,7 +476,6 @@ requires-dist = [{ name = "typing-extensions", specifier = ">=4.12.2" }] [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", specifier = ">=1.15.0" }, { name = "pydoctor", specifier = ">=25.4.0" }, { name = "pyright", specifier = ">=1.1.400" }, @@ -738,15 +684,6 @@ 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 = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "toml" version = "0.10.2" From 833a4c69f2f3f37328daece5fd5eecf1bd6762be Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 10:39:08 -0400 Subject: [PATCH 087/178] Make Content.data required --- src/nexusrpc/_serializer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index 38f87c7..794e517 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -29,8 +29,8 @@ class Content: User provided keys are treated case-insensitively. """ - data: Optional[bytes] = None - """Request or response data. May be undefined for empty data.""" + data: Optional[bytes] + """Request or response data.""" class Serializer(Protocol): @@ -89,7 +89,7 @@ async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? if self.stream is None: return await self.serializer.deserialize( - Content(headers=self.headers), as_type=as_type + Content(headers=self.headers, data=None), as_type=as_type ) elif not isinstance(self.stream, AsyncIterable): raise ValueError("When using consume, stream must be an AsyncIterable") @@ -109,7 +109,7 @@ def consume_sync(self, as_type: Optional[Type[Any]] = None) -> Any: # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? if self.stream is None: return self.serializer.deserialize( - Content(headers=self.headers), as_type=as_type + Content(headers=self.headers, data=None), as_type=as_type ) elif not isinstance(self.stream, Iterable): raise ValueError("When using consume_sync, stream must be an Iterable") From 549b81ffcdc8c77f37b97d3c4f6ece54ed1c4e23 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 10:42:53 -0400 Subject: [PATCH 088/178] Rename method: from_user_class -> from_class --- src/nexusrpc/_service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index c5c7e58..2c3a2e2 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -115,7 +115,7 @@ class MyNexusService: def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: if name is not None and not name: raise ValueError("Service name must not be empty.") - defn = ServiceDefinition.from_user_class(cls, name or cls.__name__) + defn = ServiceDefinition.from_class(cls, name or cls.__name__) setattr(cls, "__nexus_service__", defn) # In order for callers to refer to operations at run-time, a decorated user @@ -139,7 +139,7 @@ class ServiceDefinition: operations: Mapping[str, Operation[Any, Any]] @staticmethod - def from_user_class( + def from_class( user_class: Type[ServiceDefinitionT], name: str ) -> ServiceDefinition: """Create a ServiceDefinition from a user service definition class. @@ -160,7 +160,7 @@ def from_user_class( return ServiceDefinition(name=user_class.__name__, operations={}) parent = user_class.mro()[1] - parent_defn = ServiceDefinition.from_user_class(parent, parent.__name__) + parent_defn = ServiceDefinition.from_class(parent, parent.__name__) # Update the inherited operations with those collected at this level. defn = ServiceDefinition( From 5f24e15b57fe7dda65163107dae216d98a0814e2 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 11:43:33 -0400 Subject: [PATCH 089/178] Relocate OperationError --- src/nexusrpc/__init__.py | 19 +++++++++++++++++++ src/nexusrpc/handler/__init__.py | 2 -- src/nexusrpc/handler/_common.py | 19 ------------------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 11ea68f..c882b15 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -46,3 +46,22 @@ class OperationInfo: # The operation's state state: OperationState + + +class OperationErrorState(Enum): + """ + The state of an operation as described by an OperationError. + """ + + FAILED = "failed" + CANCELED = "canceled" + + +class OperationError(Exception): + """ + An error that represents "failed" and "canceled" operation results. + """ + + def __init__(self, message: str, *, state: OperationErrorState): + super().__init__(message) + self.state = state diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index d9b79f6..d4dc651 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -17,8 +17,6 @@ HandlerError as HandlerError, HandlerErrorType as HandlerErrorType, OperationContext as OperationContext, - OperationError as OperationError, - OperationErrorState as OperationErrorState, StartOperationContext as StartOperationContext, StartOperationResultAsync as StartOperationResultAsync, StartOperationResultSync as StartOperationResultSync, diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index f8f4c7c..a83a6a5 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -66,25 +66,6 @@ def __init__( self.retryable = retryable -class OperationErrorState(Enum): - """ - The state of an operation as described by an OperationError. - """ - - FAILED = "failed" - CANCELED = "canceled" - - -class OperationError(Exception): - """ - An error that represents "failed" and "canceled" operation results. - """ - - def __init__(self, message: str, *, state: OperationErrorState): - super().__init__(message) - self.state = state - - @dataclass class OperationContext: """Context for the execution of the requested operation method. From e2cf7fe19dcb997d3b598de0fdcc73b1eb0cd2eb Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 11:51:47 -0400 Subject: [PATCH 090/178] Make dataclasses `frozen` --- src/nexusrpc/handler/_common.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index a83a6a5..4c59c69 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -66,7 +66,7 @@ def __init__( self.retryable = retryable -@dataclass +@dataclass(frozen=True) class OperationContext: """Context for the execution of the requested operation method. @@ -80,7 +80,7 @@ class OperationContext: headers: Mapping[str, str] = field(default_factory=dict) -@dataclass +@dataclass(frozen=True) class StartOperationContext(OperationContext): """Context for the start method. @@ -109,21 +109,21 @@ class StartOperationContext(OperationContext): outbound_links: list[Link] = field(default_factory=list) -@dataclass +@dataclass(frozen=True) class CancelOperationContext(OperationContext): """Context for the cancel method. Includes information from the request.""" -@dataclass +@dataclass(frozen=True) class FetchOperationInfoContext(OperationContext): """Context for the fetch_info method. Includes information from the request.""" -@dataclass +@dataclass(frozen=True) class FetchOperationResultContext(OperationContext): """Context for the fetch_result method. From cc43cdd93e2187acf6545262e828183e8fc6d540 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 11:51:56 -0400 Subject: [PATCH 091/178] Make OperationContext non-instantiatable --- src/nexusrpc/handler/_common.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 4c59c69..b5250e2 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -72,6 +72,13 @@ class OperationContext: 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) + # The name of the service that the operation belongs to. service: str # The name of the operation. From 3b6500babcd3a7d5a21806b63244ff2c5e5025ed Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 12:11:52 -0400 Subject: [PATCH 092/178] Make dataclasses frozen=True --- src/nexusrpc/__init__.py | 4 ++-- src/nexusrpc/_serializer.py | 2 +- src/nexusrpc/_service.py | 2 +- src/nexusrpc/handler/_common.py | 4 ++-- src/nexusrpc/handler/_core.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index c882b15..c5e89f4 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -9,7 +9,7 @@ ) -@dataclass +@dataclass(frozen=True) class Link: """ Link contains a URL and a Type that can be used to decode the URL. @@ -35,7 +35,7 @@ class OperationState(Enum): RUNNING = "running" -@dataclass +@dataclass(frozen=True) class OperationInfo: """ Information about an operation. diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index 794e517..5a594bd 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -14,7 +14,7 @@ ) -@dataclass +@dataclass(frozen=True) class Content: """ A container for a map of headers and a byte array of data. diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index 2c3a2e2..850bda1 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -133,7 +133,7 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: return decorator(cls) -@dataclass +@dataclass(frozen=True) class ServiceDefinition: name: str operations: Mapping[str, Operation[Any, Any]] diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index b5250e2..918d555 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -138,7 +138,7 @@ class FetchOperationResultContext(OperationContext): # TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? -@dataclass +@dataclass(frozen=True) class StartOperationResultSync(Generic[OutputT]): """ A result returned synchronously by the start method of a nexus operation handler. @@ -149,7 +149,7 @@ class StartOperationResultSync(Generic[OutputT]): # TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? # TODO(prelease) Demonstrate a type-safe fetch_result -@dataclass +@dataclass(frozen=True) class StartOperationResultAsync: """ A value returned by the start method of a nexus operation handler indicating that diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index a753360..c3855a5 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -353,7 +353,7 @@ def fetch_operation_result( raise NotImplementedError -@dataclass +@dataclass(frozen=True) class ServiceHandler: """Internal representation of a user's Nexus service implementation instance. From e514cdf0fd41b691f7f2e6d26c28de860c0bb694 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 16:08:49 -0400 Subject: [PATCH 093/178] Make request ID required --- src/nexusrpc/handler/_common.py | 10 +++++----- tests/handler/test_async_operation.py | 5 +++++ tests/handler/test_handler_syncio.py | 2 ++ ...lts_in_correctly_functioning_operation_factories.py | 2 ++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 918d555..262d03c 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -84,7 +84,7 @@ def __new__(cls, *args, **kwargs): # The name of the operation. operation: str # Optional header fields sent by the caller. - headers: Mapping[str, str] = field(default_factory=dict) + headers: Mapping[str, str] @dataclass(frozen=True) @@ -93,6 +93,10 @@ class StartOperationContext(OperationContext): Includes information from the request.""" + # Request ID that may be used by the handler to dedupe a start request. + # By default a v4 UUID will be generated by the client. + request_id: str + # 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_url: Optional[str] = None @@ -101,10 +105,6 @@ class StartOperationContext(OperationContext): # asynchronous operation completes. callback_headers: Mapping[str, str] = field(default_factory=dict) - # Request ID that may be used by the handler to dedupe a start request. - # By default a v4 UUID will be generated by the client. - request_id: Optional[str] = None - # 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. diff --git a/tests/handler/test_async_operation.py b/tests/handler/test_async_operation.py index 22f5c6c..97fb20f 100644 --- a/tests/handler/test_async_operation.py +++ b/tests/handler/test_async_operation.py @@ -62,6 +62,8 @@ async def test_async_operation_happy_path(): start_ctx = StartOperationContext( service="MyService", operation="incr", + headers={}, + request_id="request_id", ) start_result = await handler.start_operation( start_ctx, LazyValue(DummySerializer(1), headers={}) @@ -72,6 +74,7 @@ async def test_async_operation_happy_path(): 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 @@ -79,6 +82,7 @@ async def test_async_operation_happy_path(): fetch_result_ctx = FetchOperationResultContext( service="MyService", operation="incr", + headers={}, ) result = await handler.fetch_operation_result(fetch_result_ctx, start_result.token) assert result == 2 @@ -86,6 +90,7 @@ async def test_async_operation_happy_path(): 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 index 255f845..881a1cf 100644 --- a/tests/handler/test_handler_syncio.py +++ b/tests/handler/test_handler_syncio.py @@ -37,6 +37,8 @@ def test_sync_handler_happy_path(test_case: Type[_TestCase]): ctx = StartOperationContext( service="MyService", operation="incr", + headers={}, + request_id="request_id", ) result = handler.start_operation(ctx, LazyValue(DummySerializer(1), headers={})) assert isinstance(result, StartOperationResultSync) 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 index baf2f04..15c4abb 100644 --- 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 @@ -95,6 +95,8 @@ async def test_collected_operation_factories_match_service_definition( ctx = StartOperationContext( service="Service", operation="operation", + headers={}, + request_id="request_id", ) async def execute( From 2d90830ca83434c7c359c891f26d60ed01075ba9 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 19:54:27 -0400 Subject: [PATCH 094/178] Eliminate types.module --- src/nexusrpc/__init__.py | 1 + src/nexusrpc/_service.py | 6 +----- src/nexusrpc/{types.py => _types.py} | 0 src/nexusrpc/handler/_common.py | 3 +-- src/nexusrpc/handler/_decorators.py | 3 ++- src/nexusrpc/handler/_operation_handler.py | 3 ++- src/nexusrpc/handler/_util.py | 15 +++++++++++++-- ...n_correctly_functioning_operation_factories.py | 2 +- 8 files changed, 21 insertions(+), 12 deletions(-) rename src/nexusrpc/{types.py => _types.py} (100%) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index c5e89f4..0160ae9 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -7,6 +7,7 @@ ServiceDefinition as ServiceDefinition, service as service, ) +from ._types import InputT as InputT, OutputT as OutputT @dataclass(frozen=True) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index 850bda1..ca26e8f 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -20,12 +20,8 @@ overload, ) +from nexusrpc._types import InputT, OutputT, ServiceDefinitionT from nexusrpc._util import get_annotations -from nexusrpc.types import ( - InputT, - OutputT, - ServiceDefinitionT, -) @dataclass diff --git a/src/nexusrpc/types.py b/src/nexusrpc/_types.py similarity index 100% rename from src/nexusrpc/types.py rename to src/nexusrpc/_types.py diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 262d03c..aab1816 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -9,8 +9,7 @@ Sequence, ) -from nexusrpc import Link -from nexusrpc.types import OutputT +from nexusrpc import Link, OutputT class HandlerErrorType(Enum): diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 1abd667..aebd465 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -15,13 +15,14 @@ ) import nexusrpc +from nexusrpc import InputT, OutputT +from nexusrpc._types import ServiceHandlerT from nexusrpc.handler._common import StartOperationContext from nexusrpc.handler._util import ( get_callable_name, get_start_method_input_and_output_type_annotations, is_async_callable, ) -from nexusrpc.types import InputT, OutputT, ServiceHandlerT from ._operation_handler import ( OperationHandler, diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index c85226d..51a85f0 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -14,8 +14,9 @@ import nexusrpc import nexusrpc._service +from nexusrpc import InputT, OutputT +from nexusrpc._types import ServiceHandlerT from nexusrpc.handler._util import get_operation_factory, is_async_callable -from nexusrpc.types import InputT, OutputT, ServiceHandlerT from .. import OperationInfo from ._common import ( diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 5ca3c0f..0f3285b 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -4,17 +4,28 @@ import inspect import typing import warnings -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type, Union +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Optional, + Type, + TypeVar, + Union, +) from typing_extensions import TypeGuard import nexusrpc +from nexusrpc import InputT, OutputT from nexusrpc.handler._common import StartOperationContext -from nexusrpc.types import InputT, OutputT, ServiceHandlerT if TYPE_CHECKING: from nexusrpc.handler._operation_handler import OperationHandler +ServiceHandlerT = TypeVar("ServiceHandlerT") + def get_start_method_input_and_output_type_annotations( start: Callable[ 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 index 15c4abb..c8a2f20 100644 --- 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 @@ -8,6 +8,7 @@ import pytest import nexusrpc._service +from nexusrpc import InputT, OutputT from nexusrpc.handler import ( CancelOperationContext, FetchOperationInfoContext, @@ -22,7 +23,6 @@ from nexusrpc.handler._core import collect_operation_handler_factories from nexusrpc.handler._decorators import operation_handler from nexusrpc.handler._util import is_async_callable -from nexusrpc.types import InputT, OutputT @dataclass From 8d875cc05c88828d1fbba652f2c0776233717e51 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 26 Jun 2025 20:52:42 -0400 Subject: [PATCH 095/178] Add FetchOperationResultContext timeout with stub implementation --- src/nexusrpc/handler/_common.py | 6 ++++++ src/nexusrpc/handler/_core.py | 5 +++++ tests/handler/test_async_operation.py | 13 +++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index aab1816..9a756f1 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import timedelta from enum import Enum from typing import ( Generic, @@ -135,6 +136,11 @@ class FetchOperationResultContext(OperationContext): Includes information from the request.""" + # 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. + wait: Optional[timedelta] = None + # TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? @dataclass(frozen=True) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index c3855a5..ad5c3b9 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -254,6 +254,11 @@ async def fetch_operation_info( 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): diff --git a/tests/handler/test_async_operation.py b/tests/handler/test_async_operation.py index 97fb20f..385b16e 100644 --- a/tests/handler/test_async_operation.py +++ b/tests/handler/test_async_operation.py @@ -1,5 +1,7 @@ +import dataclasses import uuid from dataclasses import dataclass +from datetime import timedelta from typing import Any, Optional, Type import pytest @@ -15,6 +17,7 @@ StartOperationResultAsync, service_handler, ) +from nexusrpc.handler._common import HandlerError, HandlerErrorType from nexusrpc.handler._decorators import operation_handler @@ -46,6 +49,11 @@ async def fetch_info( ) 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] @@ -87,6 +95,11 @@ async def test_async_operation_happy_path(): 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", From 657602ee5238375f929103ed16e2b1556513ccc4 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 12:43:37 -0400 Subject: [PATCH 096/178] Cleanup --- tests/handler/test_async_operation.py | 3 ++- ...orrectly_functioning_operation_factories.py | 2 +- ...r_decorator_selects_correct_service_name.py | 18 ++++++++---------- ...e_decorator_selects_correct_service_name.py | 2 +- .../test_service_definition_inheritance.py | 4 ++-- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/handler/test_async_operation.py b/tests/handler/test_async_operation.py index 385b16e..8b7ae7e 100644 --- a/tests/handler/test_async_operation.py +++ b/tests/handler/test_async_operation.py @@ -12,12 +12,13 @@ FetchOperationInfoContext, FetchOperationResultContext, Handler, + HandlerError, + HandlerErrorType, OperationHandler, StartOperationContext, StartOperationResultAsync, service_handler, ) -from nexusrpc.handler._common import HandlerError, HandlerErrorType from nexusrpc.handler._decorators import operation_handler 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 index c8a2f20..c09ca84 100644 --- 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 @@ -7,7 +7,7 @@ import pytest -import nexusrpc._service +import nexusrpc from nexusrpc import InputT, OutputT from nexusrpc.handler import ( CancelOperationContext, 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 index 59050e5..608dc4f 100644 --- a/tests/handler/test_service_handler_decorator_selects_correct_service_name.py +++ b/tests/handler/test_service_handler_decorator_selects_correct_service_name.py @@ -3,7 +3,7 @@ import pytest import nexusrpc -import nexusrpc.handler +from nexusrpc.handler import service_handler @nexusrpc.service @@ -23,7 +23,7 @@ class _NameOverrideTestCase: class NotCalled(_NameOverrideTestCase): - @nexusrpc.handler.service_handler + @service_handler class ServiceImpl: pass @@ -31,7 +31,7 @@ class ServiceImpl: class CalledWithoutArgs(_NameOverrideTestCase): - @nexusrpc.handler.service_handler() + @service_handler() class ServiceImpl: pass @@ -39,7 +39,7 @@ class ServiceImpl: class CalledWithNameArg(_NameOverrideTestCase): - @nexusrpc.handler.service_handler(name="my-service-impl-🌈") + @service_handler(name="my-service-impl-🌈") class ServiceImpl: pass @@ -47,7 +47,7 @@ class ServiceImpl: class CalledWithInterface(_NameOverrideTestCase): - @nexusrpc.handler.service_handler(service=ServiceInterface) + @service_handler(service=ServiceInterface) class ServiceImpl: pass @@ -55,7 +55,7 @@ class ServiceImpl: class CalledWithInterfaceWithNameOverride(_NameOverrideTestCase): - @nexusrpc.handler.service_handler(service=ServiceInterfaceWithNameOverride) + @service_handler(service=ServiceInterfaceWithNameOverride) class ServiceImpl: pass @@ -80,11 +80,9 @@ def test_service_decorator_name_overrides(test_case: Type[_NameOverrideTestCase] def test_name_must_not_be_empty(): with pytest.raises(ValueError): - nexusrpc.handler.service_handler(name="")(object) + service_handler(name="")(object) def test_name_and_interface_are_mutually_exclusive(): with pytest.raises(ValueError): - nexusrpc.handler.service_handler( - name="my-service-impl-🌈", service=ServiceInterface - ) # type: ignore (enforced by overloads) + service_handler(name="my-service-impl-🌈", service=ServiceInterface) # type: ignore (enforced by overloads) 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 index 64e94c9..ccc090e 100644 --- a/tests/service_definition/test_service_decorator_selects_correct_service_name.py +++ b/tests/service_definition/test_service_decorator_selects_correct_service_name.py @@ -2,7 +2,7 @@ import pytest -import nexusrpc._service +import nexusrpc class NameOverrideTestCase: diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 8023005..cf59712 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -115,5 +115,5 @@ def test_user_service_definition_inheritance(test_case: Type[_TestCase]): 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 == int - assert op.output_type == str + assert op.input_type is int + assert op.output_type is str From 1b3f7d03a42d20dce4c8583920a91ba45b8b04b7 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 12:35:17 -0400 Subject: [PATCH 097/178] Cleanup --- src/nexusrpc/_service.py | 5 +++-- src/nexusrpc/handler/_decorators.py | 10 +++++----- src/nexusrpc/handler/_util.py | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index ca26e8f..deb56e3 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -22,6 +22,7 @@ from nexusrpc._types import InputT, OutputT, ServiceDefinitionT from nexusrpc._util import get_annotations +from nexusrpc.handler._util import get_service_definition, set_service_definition @dataclass @@ -112,7 +113,7 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: 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__) - setattr(cls, "__nexus_service__", defn) + 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 @@ -148,7 +149,7 @@ def from_class( # If this class is decorated then return the already-computed ServiceDefinition. # Do not use getattr since it would retrieve a value from a decorated parent class. - if defn := user_class.__dict__.get("__nexus_service__"): + if defn := get_service_definition(user_class): if isinstance(defn, ServiceDefinition): return defn diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index aebd465..8e1dac4 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -20,8 +20,10 @@ from nexusrpc.handler._common import StartOperationContext from nexusrpc.handler._util import ( get_callable_name, + get_service_definition, get_start_method_input_and_output_type_annotations, is_async_callable, + set_service_definition, ) from ._operation_handler import ( @@ -98,13 +100,11 @@ class name will be used. ) _service = None if service: - # TODO(prerelease): This allows a non-decorated class to act as a service - # definition if it inherits from a decorated class. Is this what we want? - _service = getattr(service, "__nexus_service__", None) + _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 your class to define a 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]: @@ -120,7 +120,7 @@ def decorator(cls: Type[ServiceHandlerT]) -> Type[ServiceHandlerT]: _name, op_factories ) validate_operation_handler_methods(cls, op_factories, service) - cls.__nexus_service__ = service # type: ignore + set_service_definition(cls, service) return cls if cls is None: diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 0f3285b..21f9da2 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -19,6 +19,7 @@ import nexusrpc from nexusrpc import InputT, OutputT +from nexusrpc._types import ServiceDefinitionT from nexusrpc.handler._common import StartOperationContext if TYPE_CHECKING: @@ -89,6 +90,24 @@ def get_operation_factory( return factory, op_defn +def get_service_definition( + cls: Type[ServiceDefinitionT], +) -> Optional[nexusrpc.ServiceDefinition]: + if not isinstance(cls, type): + raise TypeError(f"Expected {cls} to be a class, but is {type(cls)}.") + # getattr would allow a non-decorated class to act as a service + # definition if it inherits from a decorated class. + return cls.__dict__.get("__nexus_service__") + + +def set_service_definition( + cls: Type[ServiceDefinitionT], service_definition: nexusrpc.ServiceDefinition +) -> None: + 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_callable_name(fn: Callable[..., Any]) -> str: method_name = getattr(fn, "__name__", None) if not method_name and callable(fn) and hasattr(fn, "__call__"): From f98a884a91f347da8197687ae70d5e90e2f30333 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 13:59:33 -0400 Subject: [PATCH 098/178] Move DummySerializer --- tests/__init__.py | 0 tests/handler/__init__.py | 0 tests/handler/test_async_operation.py | 19 +++---------------- tests/helpers.py | 17 +++++++++++++++++ 4 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/handler/__init__.py create mode 100644 tests/helpers.py 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 index 8b7ae7e..c10eff2 100644 --- a/tests/handler/test_async_operation.py +++ b/tests/handler/test_async_operation.py @@ -1,12 +1,11 @@ import dataclasses import uuid -from dataclasses import dataclass from datetime import timedelta -from typing import Any, Optional, Type +from typing import Any import pytest -from nexusrpc import Content, LazyValue, OperationInfo, OperationState +from nexusrpc import LazyValue, OperationInfo, OperationState from nexusrpc.handler import ( CancelOperationContext, FetchOperationInfoContext, @@ -20,6 +19,7 @@ service_handler, ) from nexusrpc.handler._decorators import operation_handler +from tests.helpers import DummySerializer class _TestCase: @@ -108,16 +108,3 @@ async def test_async_operation_happy_path(): ) await handler.cancel_operation(cancel_ctx, start_result.token) assert start_result.token not in _operation_results - - -@dataclass -class DummySerializer: - value: int - - 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/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 From 60c2c9e3dac0d7e9e3a7e39f15b56e5f40aec45d Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 13:54:54 -0400 Subject: [PATCH 099/178] Work around lack of proper subtype compatibility check for parameterized generics --- src/nexusrpc/handler/_operation_handler.py | 6 +++--- src/nexusrpc/handler/_util.py | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 51a85f0..b9a5628 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -16,7 +16,7 @@ import nexusrpc._service from nexusrpc import InputT, OutputT from nexusrpc._types import ServiceHandlerT -from nexusrpc.handler._util import get_operation_factory, is_async_callable +from nexusrpc.handler._util import get_operation_factory, is_async_callable, is_subtype from .. import OperationInfo from ._common import ( @@ -265,7 +265,7 @@ def validate_operation_handler_methods( 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 issubclass(op_defn.input_type, method_op_defn.input_type) + or is_subtype(op_defn.input_type, method_op_defn.input_type) ) ): raise TypeError( @@ -280,7 +280,7 @@ def validate_operation_handler_methods( 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 issubclass(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_name}' in service '{user_service_cls}' has output type '{method_op_defn.output_type}', " diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 21f9da2..4b36a97 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -23,7 +23,7 @@ from nexusrpc.handler._common import StartOperationContext if TYPE_CHECKING: - from nexusrpc.handler._operation_handler import OperationHandler + from nexusrpc.handler._operation import OperationHandler ServiceHandlerT = TypeVar("ServiceHandlerT") @@ -120,6 +120,14 @@ def get_callable_name(fn: Callable[..., Any]) -> str: 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) + + # Copied from https://github.com/modelcontextprotocol/python-sdk # # Copyright (c) 2024 Anthropic, PBC. From b3bb2d38aa9741fd5cfb0ec9b1ad9c58a6e3a92e Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 12:38:27 -0400 Subject: [PATCH 100/178] get_service_definition cleanup --- src/nexusrpc/_service.py | 7 +++-- src/nexusrpc/_util.py | 28 +++++++++++++++++++ src/nexusrpc/handler/_core.py | 4 +-- src/nexusrpc/handler/_decorators.py | 3 +- src/nexusrpc/handler/_util.py | 19 ------------- ...collects_expected_operation_definitions.py | 17 +++++------ ..._service_handler_decorator_requirements.py | 3 +- ...rrectly_functioning_operation_factories.py | 5 ++-- ..._decorator_selects_correct_service_name.py | 3 +- ..._creates_expected_operation_declaration.py | 5 ++-- ..._decorator_selects_correct_service_name.py | 6 ++-- .../test_service_definition_inheritance.py | 3 +- 12 files changed, 60 insertions(+), 43 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index deb56e3..9b85840 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -21,8 +21,11 @@ ) from nexusrpc._types import InputT, OutputT, ServiceDefinitionT -from nexusrpc._util import get_annotations -from nexusrpc.handler._util import get_service_definition, set_service_definition +from nexusrpc._util import ( + get_annotations, + get_service_definition, + set_service_definition, +) @dataclass diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index a266ddd..5487424 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -1,5 +1,33 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Type + +if TYPE_CHECKING: + import nexusrpc + from nexusrpc._types import ServiceDefinitionT + + +def get_service_definition( + cls: Type[ServiceDefinitionT], +) -> Optional[nexusrpc.ServiceDefinition]: + if not isinstance(cls, type): + raise TypeError(f"Expected {cls} to be a class, but is {type(cls)}.") + # getattr would allow a non-decorated class to act as a service + # definition if it inherits from a decorated class. + return cls.__dict__.get("__nexus_service__") + + +def set_service_definition( + cls: Type[ServiceDefinitionT], service_definition: nexusrpc.ServiceDefinition +) -> None: + if not isinstance(cls, type): + raise TypeError(f"Expected {cls} to be a class, but is {type(cls)}.") + setattr(cls, "__nexus_service__", service_definition) + + # 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: diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index ad5c3b9..dfe36aa 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -17,7 +17,7 @@ from typing_extensions import Self import nexusrpc -import nexusrpc._service +from nexusrpc._util import get_service_definition from nexusrpc.handler._util import is_async_callable from .. import OperationInfo @@ -382,7 +382,7 @@ class contains the :py:class:`OperationHandler` instances themselves. def from_user_instance(cls, user_instance: Any) -> Self: """Create a :py:class:`ServiceHandler` from a user service instance.""" - service = getattr(user_instance.__class__, "__nexus_service__", None) + service = get_service_definition(user_instance.__class__) if not isinstance(service, nexusrpc.ServiceDefinition): raise RuntimeError( f"Service '{user_instance}' does not have a service definition. " diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 8e1dac4..f235cb4 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -17,13 +17,12 @@ import nexusrpc from nexusrpc import InputT, OutputT from nexusrpc._types import ServiceHandlerT +from nexusrpc._util import get_service_definition, set_service_definition from nexusrpc.handler._common import StartOperationContext from nexusrpc.handler._util import ( get_callable_name, - get_service_definition, get_start_method_input_and_output_type_annotations, is_async_callable, - set_service_definition, ) from ._operation_handler import ( diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 4b36a97..5da446c 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -19,7 +19,6 @@ import nexusrpc from nexusrpc import InputT, OutputT -from nexusrpc._types import ServiceDefinitionT from nexusrpc.handler._common import StartOperationContext if TYPE_CHECKING: @@ -90,24 +89,6 @@ def get_operation_factory( return factory, op_defn -def get_service_definition( - cls: Type[ServiceDefinitionT], -) -> Optional[nexusrpc.ServiceDefinition]: - if not isinstance(cls, type): - raise TypeError(f"Expected {cls} to be a class, but is {type(cls)}.") - # getattr would allow a non-decorated class to act as a service - # definition if it inherits from a decorated class. - return cls.__dict__.get("__nexus_service__") - - -def set_service_definition( - cls: Type[ServiceDefinitionT], service_definition: nexusrpc.ServiceDefinition -) -> None: - 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_callable_name(fn: Callable[..., Any]) -> str: method_name = getattr(fn, "__name__", None) if not method_name and callable(fn) and hasattr(fn, "__call__"): 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 index 971ca0c..fb628cc 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -9,6 +9,7 @@ import pytest import nexusrpc +from nexusrpc._util import get_service_definition from nexusrpc.handler import ( OperationHandler, StartOperationContext, @@ -203,15 +204,15 @@ async def test_collected_operation_definitions( if test_case.skip: pytest.skip(test_case.skip) - service: nexusrpc.ServiceDefinition = getattr( - test_case.Service, "__nexus_service__" - ) + service = get_service_definition(test_case.Service) assert isinstance(service, nexusrpc.ServiceDefinition) - assert ( - service.name == "Service" - if test_case.Contract is None - else test_case.Contract.__nexus_service__.name # type: ignore - ) + 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 = getattr(test_case.Service, method_name).__nexus_operation__ assert isinstance(actual_op, nexusrpc.Operation) diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 8e5ea70..3ed43c0 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -5,6 +5,7 @@ import pytest import nexusrpc +from nexusrpc._util import get_service_definition from nexusrpc.handler import ( OperationHandler, service_handler, @@ -154,7 +155,7 @@ class UserService(BaseUserService): def test_service_definition_inheritance_behavior( test_case: _ServiceDefinitionInheritanceTestCase, ): - service_defn = getattr(test_case.UserService, "__nexus_service__", None) + service_defn = get_service_definition(test_case.UserService) assert service_defn is not None, ( f"{test_case.UserService.__name__} lacks __nexus_service__ attribute." 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 index c09ca84..ac8a385 100644 --- 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 @@ -9,6 +9,7 @@ import nexusrpc from nexusrpc import InputT, OutputT +from nexusrpc._util import get_service_definition from nexusrpc.handler import ( CancelOperationContext, FetchOperationInfoContext, @@ -83,9 +84,7 @@ async def sync_operation_handler( async def test_collected_operation_factories_match_service_definition( test_case: Type[_TestCase], ): - service: nexusrpc.ServiceDefinition = getattr( - test_case.Service, "__nexus_service__" - ) + service = get_service_definition(test_case.Service) assert isinstance(service, nexusrpc.ServiceDefinition) assert service.name == "Service" operation_factories = collect_operation_handler_factories( 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 index 608dc4f..ea8846c 100644 --- a/tests/handler/test_service_handler_decorator_selects_correct_service_name.py +++ b/tests/handler/test_service_handler_decorator_selects_correct_service_name.py @@ -3,6 +3,7 @@ import pytest import nexusrpc +from nexusrpc._util import get_service_definition from nexusrpc.handler import service_handler @@ -73,7 +74,7 @@ class ServiceImpl: ], ) def test_service_decorator_name_overrides(test_case: Type[_NameOverrideTestCase]): - service = getattr(test_case.ServiceImpl, "__nexus_service__") + service = get_service_definition(test_case.ServiceImpl) assert isinstance(service, nexusrpc.ServiceDefinition) assert service.name == test_case.expected_name 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 index acb99cb..fec974c 100644 --- a/tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py +++ b/tests/service_definition/test_service_decorator_creates_expected_operation_declaration.py @@ -3,6 +3,7 @@ import pytest import nexusrpc +from nexusrpc._util import get_service_definition class Output: @@ -35,8 +36,8 @@ class Interface: def test_interface_operation_declarations( test_case: Type[OperationDeclarationTestCase], ): - metadata = getattr(test_case.Interface, "__nexus_service__") - assert isinstance(metadata, nexusrpc.ServiceDefinition) + 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() 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 index ccc090e..6a52176 100644 --- a/tests/service_definition/test_service_decorator_selects_correct_service_name.py +++ b/tests/service_definition/test_service_decorator_selects_correct_service_name.py @@ -3,6 +3,7 @@ import pytest import nexusrpc +from nexusrpc._util import get_service_definition class NameOverrideTestCase: @@ -43,8 +44,9 @@ class Interface: ], ) def test_interface_name_overrides(test_case: Type[NameOverrideTestCase]): - metadata = getattr(test_case.Interface, "__nexus_service__") - assert metadata.name == test_case.expected_name + defn = get_service_definition(test_case.Interface) + assert defn + assert defn.name == test_case.expected_name def test_name_must_not_be_empty(): diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index cf59712..217d575 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -8,6 +8,7 @@ import pytest from nexusrpc import Operation, ServiceDefinition, service +from nexusrpc._util import get_service_definition # See https://docs.python.org/3/howto/annotations.html @@ -111,7 +112,7 @@ def test_user_service_definition_inheritance(test_case: Type[_TestCase]): service(test_case.UserService) return - service_defn = getattr(service(test_case.UserService), "__nexus_service__", None) + service_defn = get_service_definition(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(): From eaf59135a2c8ff541f85a7fac503d4b7c4753629 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 13:07:22 -0400 Subject: [PATCH 101/178] Make get_service_definition public --- src/nexusrpc/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 0160ae9..1bdf330 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -8,6 +8,7 @@ service as service, ) from ._types import InputT as InputT, OutputT as OutputT +from ._util import get_service_definition as get_service_definition @dataclass(frozen=True) From 8b1b1a970f1c14050bc380693bd1b6b7a32b7256 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 13:09:52 -0400 Subject: [PATCH 102/178] Don't raise in get_service_definition --- src/nexusrpc/_util.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index 5487424..a377399 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Type +from typing import TYPE_CHECKING, Any, Optional, Type if TYPE_CHECKING: import nexusrpc @@ -8,23 +8,33 @@ def get_service_definition( - cls: Type[ServiceDefinitionT], + obj: Any, ) -> Optional[nexusrpc.ServiceDefinition]: - if not isinstance(cls, type): - raise TypeError(f"Expected {cls} to be a class, but is {type(cls)}.") + """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. - return cls.__dict__.get("__nexus_service__") + if isinstance(obj, type): + return obj.__dict__.get("__nexus_service__") + else: + return getattr(obj, "__dict__", {}).get("__nexus_service__") def set_service_definition( cls: Type[ServiceDefinitionT], 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""" + return getattr(obj, "__nexus_operation__", None) + + # See # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older From 4f228221d6131c08a5c7491f85b53e3b7996e864 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 14:12:04 -0400 Subject: [PATCH 103/178] Move get_operation_factory --- src/nexusrpc/__init__.py | 5 +++- src/nexusrpc/_util.py | 23 ++++++++++++++++++- src/nexusrpc/handler/__init__.py | 1 - src/nexusrpc/handler/_operation_handler.py | 4 ++-- src/nexusrpc/handler/_util.py | 22 ------------------ ...corator_creates_valid_operation_handler.py | 3 ++- 6 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 1bdf330..bc7400d 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -8,7 +8,10 @@ service as service, ) from ._types import InputT as InputT, OutputT as OutputT -from ._util import get_service_definition as get_service_definition +from ._util import ( + get_operation_factory as get_operation_factory, + get_service_definition as get_service_definition, +) @dataclass(frozen=True) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index a377399..e2d06ac 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -1,10 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional, Type +from typing import TYPE_CHECKING, Any, Callable, Optional, Type + +import nexusrpc if TYPE_CHECKING: import nexusrpc + from nexusrpc import InputT, OutputT from nexusrpc._types import ServiceDefinitionT + from nexusrpc.handler._operation_handler import OperationHandler def get_service_definition( @@ -35,6 +39,23 @@ def get_operation_definition( return getattr(obj, "__nexus_operation__", None) +def get_operation_factory( + obj: Any, +) -> tuple[ + Optional[Callable[[Any], OperationHandler[InputT, OutputT]]], + Optional[nexusrpc.Operation[InputT, OutputT]], +]: + op_defn = getattr(obj, "__nexus_operation__", None) + if op_defn: + factory = obj + else: + if factory := getattr(obj, "__nexus_operation_factory__", None): + op_defn = getattr(factory, "__nexus_operation__", None) + if not isinstance(op_defn, nexusrpc.Operation): + return None, None + return factory, op_defn + + # See # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index d4dc651..3157d9c 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -31,6 +31,5 @@ ) from ._operation_handler import OperationHandler as OperationHandler from ._util import ( - get_operation_factory as get_operation_factory, get_start_method_input_and_output_type_annotations as get_start_method_input_and_output_type_annotations, ) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index b9a5628..de2ded6 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -14,9 +14,9 @@ import nexusrpc import nexusrpc._service -from nexusrpc import InputT, OutputT +from nexusrpc import InputT, OutputT, get_operation_factory from nexusrpc._types import ServiceHandlerT -from nexusrpc.handler._util import get_operation_factory, is_async_callable, is_subtype +from nexusrpc.handler._util import is_async_callable, is_subtype from .. import OperationInfo from ._common import ( diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 5da446c..26eca2d 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -5,7 +5,6 @@ import typing import warnings from typing import ( - TYPE_CHECKING, Any, Awaitable, Callable, @@ -17,13 +16,9 @@ from typing_extensions import TypeGuard -import nexusrpc from nexusrpc import InputT, OutputT from nexusrpc.handler._common import StartOperationContext -if TYPE_CHECKING: - from nexusrpc.handler._operation import OperationHandler - ServiceHandlerT = TypeVar("ServiceHandlerT") @@ -72,23 +67,6 @@ def get_start_method_input_and_output_type_annotations( return input_type, output_type -def get_operation_factory( - obj: Any, -) -> tuple[ - Optional[Callable[[Any], OperationHandler[InputT, OutputT]]], - Optional[nexusrpc.Operation[InputT, OutputT]], -]: - op_defn = getattr(obj, "__nexus_operation__", None) - if op_defn: - factory = obj - else: - if factory := getattr(obj, "__nexus_operation_factory__", None): - op_defn = getattr(factory, "__nexus_operation__", None) - if not isinstance(op_defn, nexusrpc.Operation): - return None, None - return factory, op_defn - - 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__"): 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 index 8da4dca..706e72c 100644 --- 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 @@ -2,13 +2,14 @@ import pytest +from nexusrpc import get_operation_factory from nexusrpc.handler import ( StartOperationContext, StartOperationResultSync, service_handler, sync_operation, ) -from nexusrpc.handler._util import get_operation_factory, is_async_callable +from nexusrpc.handler._util import is_async_callable @service_handler From 1d63015d689ed335b873d71817b48dc092a1c3cd Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 16:47:26 -0400 Subject: [PATCH 104/178] Fixup operation getters/setters --- src/nexusrpc/_util.py | 20 +++++++++-- src/nexusrpc/handler/_decorators.py | 35 ++++++++++++------- ...collects_expected_operation_definitions.py | 4 +-- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index e2d06ac..2eac321 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -39,23 +39,39 @@ def get_operation_definition( 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.""" + setattr(obj, "__nexus_operation__", operation_definition) + + def get_operation_factory( obj: Any, ) -> tuple[ Optional[Callable[[Any], OperationHandler[InputT, OutputT]]], Optional[nexusrpc.Operation[InputT, OutputT]], ]: - op_defn = getattr(obj, "__nexus_operation__", None) + op_defn = get_operation_definition(obj) if op_defn: factory = obj else: if factory := getattr(obj, "__nexus_operation_factory__", None): - op_defn = getattr(factory, "__nexus_operation__", 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.""" + setattr(obj, "__nexus_operation_factory__", operation_factory) + + # See # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index f235cb4..e391237 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -17,7 +17,12 @@ import nexusrpc from nexusrpc import InputT, OutputT from nexusrpc._types import ServiceHandlerT -from nexusrpc._util import get_service_definition, set_service_definition +from nexusrpc._util import ( + get_service_definition, + set_operation_definition, + set_operation_factory, + set_service_definition, +) from nexusrpc.handler._common import StartOperationContext from nexusrpc.handler._util import ( get_callable_name, @@ -195,11 +200,14 @@ def decorator( f"but operation {method.__name__} has {len(type_args)} type parameters: {type_args}" ) - method.__nexus_operation__ = nexusrpc.Operation( - name=name or method.__name__, - method_name=method.__name__, - input_type=input_type, - output_type=output_type, + set_operation_definition( + method, + nexusrpc.Operation( + name=name or method.__name__, + method_name=method.__name__, + input_type=input_type, + output_type=output_type, + ), ) return method @@ -304,14 +312,17 @@ def _start_sync(ctx: StartOperationContext, input: InputT) -> OutputT: ) method_name = get_callable_name(start) - operation_handler_factory.__nexus_operation__ = nexusrpc.Operation( - name=name or method_name, - method_name=method_name, - input_type=input_type, - output_type=output_type, + 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, + ), ) - start.__nexus_operation_factory__ = operation_handler_factory + set_operation_factory(start, operation_handler_factory) return start if start is None: 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 index fb628cc..75fb59e 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -9,7 +9,7 @@ import pytest import nexusrpc -from nexusrpc._util import get_service_definition +from nexusrpc._util import get_operation_definition, get_service_definition from nexusrpc.handler import ( OperationHandler, StartOperationContext, @@ -214,7 +214,7 @@ async def test_collected_operation_definitions( assert service.name == "Service" for method_name, expected_op in test_case.expected_operations.items(): - actual_op = getattr(test_case.Service, method_name).__nexus_operation__ + 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 From 7d466acfbf83c9948029cc11e264b77106e84459 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 12:26:53 -0400 Subject: [PATCH 105/178] test_request_routing --- tests/handler/test_request_routing.py | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/handler/test_request_routing.py diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py new file mode 100644 index 0000000..19acdf1 --- /dev/null +++ b/tests/handler/test_request_routing.py @@ -0,0 +1,85 @@ +from typing import Any, 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] + UserServiceHandler: Type[Any] + # `expected` is (request, handler), where both request and handler are + # (service_name, op_name) + supported_request: tuple[str, str] + + +class NoOverrides(_TestCase): + @nexusrpc.service + class UserService: + op: nexusrpc.Operation[None, bool] + + @service_handler(service=UserService) + class UserServiceHandler: + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> bool: + assert (service_defn := get_service_definition(self.__class__)) + _, op_defn = get_operation_factory(self.op) + assert op_defn + assert ctx.service == service_defn.name + assert ctx.operation == op_defn.name + return True + + supported_request = ("UserService", "op") + + +class OverrideServiceName(_TestCase): + @nexusrpc.service(name="UserServiceNameOverride") + class UserService: + op: nexusrpc.Operation[None, bool] + + @service_handler(service=UserService) + class UserServiceHandler: + @sync_operation + async def op(self, ctx: StartOperationContext, input: None) -> bool: + assert (service_defn := get_service_definition(self.__class__)) + _, op_defn = get_operation_factory(self.op) + assert op_defn + assert ctx.service == service_defn.name + assert ctx.operation == op_defn.name + return True + + supported_request = ("UserServiceNameOverride", "op") + + +@pytest.mark.parametrize( + "test_case", + [ + NoOverrides, + OverrideServiceName, + ], +) +@pytest.mark.asyncio +async def test_request_routing_with_service_definition(test_case: _TestCase): + handler = Handler(user_service_handlers=[test_case.UserServiceHandler()]) + request_service, request_op = test_case.supported_request + ctx = StartOperationContext( + service=request_service, + operation=request_op, + headers={}, + request_id="request-id", + ) + result = await handler.start_operation( + ctx, LazyValue(serializer=DummySerializer(None), headers={}) + ) + assert cast(StartOperationResultSync[bool], result).value is True From 7f3c6e06f30fe3563be40d2df399edba39eff191 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 14:40:54 -0400 Subject: [PATCH 106/178] Refactor --- tests/handler/test_request_routing.py | 48 +++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py index 19acdf1..98ebbfb 100644 --- a/tests/handler/test_request_routing.py +++ b/tests/handler/test_request_routing.py @@ -18,11 +18,19 @@ class _TestCase: UserService: Type[Any] - UserServiceHandler: Type[Any] # `expected` is (request, handler), where both request and handler are # (service_name, op_name) supported_request: tuple[str, str] + class UserServiceHandler: + async def op(self, ctx: StartOperationContext, input: None) -> bool: + assert (service_defn := get_service_definition(self.__class__)) + _, op_defn = get_operation_factory(self.op) + assert op_defn + assert ctx.service == service_defn.name + assert ctx.operation == op_defn.name + return True + class NoOverrides(_TestCase): @nexusrpc.service @@ -30,36 +38,41 @@ class UserService: op: nexusrpc.Operation[None, bool] @service_handler(service=UserService) - class UserServiceHandler: + class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: - assert (service_defn := get_service_definition(self.__class__)) - _, op_defn = get_operation_factory(self.op) - assert op_defn - assert ctx.service == service_defn.name - assert ctx.operation == op_defn.name - return True + return await super().op(ctx, input) supported_request = ("UserService", "op") class OverrideServiceName(_TestCase): - @nexusrpc.service(name="UserServiceNameOverride") + @nexusrpc.service(name="UserServiceRenamed") class UserService: op: nexusrpc.Operation[None, bool] @service_handler(service=UserService) - class UserServiceHandler: + class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: - assert (service_defn := get_service_definition(self.__class__)) - _, op_defn = get_operation_factory(self.op) - assert op_defn - assert ctx.service == service_defn.name - assert ctx.operation == op_defn.name - return True + return await super().op(ctx, input) + + supported_request = ("UserServiceRenamed", "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): + # TODO(prerelease): this should not be required + @sync_operation(name="op-renamed") + async def op(self, ctx: StartOperationContext, input: None) -> bool: + return await super().op(ctx, input) - supported_request = ("UserServiceNameOverride", "op") + supported_request = ("UserService", "op-renamed") @pytest.mark.parametrize( @@ -67,6 +80,7 @@ async def op(self, ctx: StartOperationContext, input: None) -> bool: [ NoOverrides, OverrideServiceName, + OverrideOperationName, ], ) @pytest.mark.asyncio From e1f4405eb92cb5cb6ee653eff52c81e0904faa5a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 16:41:18 -0400 Subject: [PATCH 107/178] Cleanup --- src/nexusrpc/handler/_core.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index dfe36aa..63d27b9 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -67,7 +67,7 @@ def __init__( 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.service_handlers: dict[str, ServiceHandler] = {} for sh in user_service_handlers: if isinstance(sh, type): raise TypeError( @@ -95,6 +95,16 @@ def __init__( ) self.service_handlers[sh.service.name] = sh + 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 + @abstractmethod def start_operation( self, @@ -143,16 +153,6 @@ def cancel_operation( """ ... - 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(BaseHandler): """ From 4f2409499a992d94434447a60b57cc2308a76548 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 18:01:31 -0400 Subject: [PATCH 108/178] Failing test --- tests/handler/test_request_routing.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py index 98ebbfb..c462065 100644 --- a/tests/handler/test_request_routing.py +++ b/tests/handler/test_request_routing.py @@ -67,8 +67,7 @@ class UserService: @service_handler(service=UserService) class UserServiceHandler(_TestCase.UserServiceHandler): - # TODO(prerelease): this should not be required - @sync_operation(name="op-renamed") + @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: return await super().op(ctx, input) From 6753fc5409e80d4aa488dd04f555bd36ffbee036 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 16:41:26 -0400 Subject: [PATCH 109/178] Documentation --- src/nexusrpc/handler/_core.py | 102 +++++++++++++++++++++++++++- src/nexusrpc/handler/_decorators.py | 8 +++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 63d27b9..0578956 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -1,3 +1,101 @@ +""" +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 +------------------------- + +4. Handler.__init__ is called with [MyServiceHandler()] + +5. A ServiceHandler instance is built from the user service handler class. The task + here is to build a map {op.name: OperationHandler} by calling factory functions. + The factory is attached to my_op. The key (op.name) is the key in the operations + map which is present in two places: on the service definition, and on the service + handler class. + What we attempt to do is call collect_operation_handler_factories. This visits + all op methods, retrieves the Operation instance that was stashed there, and uses + op.name as the key. But this Operation instance was created by sync_operation, and + this should not have to know the override name provided in the service definition. + + In this case, what we want is the Operation instance inside the ServiceDefinition + attached to the service handler class. + + [Incidentally, note that we already visited all the op methods in sync_operation, + in order to gather factories to create a service definition. When the service + definition is supplied, we don't need to do this, but currently we do, and we check + the collected factory input/output types against the service definition.] + + +Request-handling time +--------------------- + +Now suppose a request has arrived for service S and operation O. + +5. The Handler does self.service_handlers[S], yielding an instance of ServiceHandler. + +6. 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. + + + + + +Case 2: There exists a user service handler class without a corresonding service definition +=========================================================================================== + +I.e., at least one user service handler class looks like + +@service_handler +class MyServiceHandler: + @sync_operation + def my_op(...) + +""" + from __future__ import annotations import asyncio @@ -366,7 +464,7 @@ class ServiceHandler: :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 handler instances + 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. @@ -389,6 +487,8 @@ def from_user_instance(cls, user_instance: Any) -> Self: f"Use the :py:func:`@nexusrpc.handler.service_handler` decorator on your class to define " f"a Nexus service implementation." ) + # Bug! If there's a service definition, name here must be taken from the + # Operation in that. op_handlers = { name: factory(user_instance) for name, factory in collect_operation_handler_factories( diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index e391237..f8f8052 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -113,17 +113,25 @@ class name will be used. def decorator(cls: Type[ServiceHandlerT]) -> Type[ServiceHandlerT]: # The name by which the service must be addressed in Nexus requests. + + # Note: this is ignored if the service definition was supplied _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.") + # Note: this is ignored if the service definition was supplied op_factories = collect_operation_handler_factories(cls, _service) + + # TODO(prerelease): if service defn was supplied then check that no operation + # handler has attempted to set a conflicting name override. + service = _service or service_definition_from_operation_handler_methods( _name, op_factories ) validate_operation_handler_methods(cls, op_factories, service) + set_service_definition(cls, service) return cls From 69a88e01feb5040bf3c937bacb01d9c6d50722ef Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 17:59:12 -0400 Subject: [PATCH 110/178] Bug fix: take op name from Operation in service definition 5. A ServiceHandler instance is built from the user service handler class. The task here is to build a map {op.name: OperationHandler} by calling factory functions. The factory is attached to my_op. The key (op.name) is the key in the operations map which is present in two places: on the service definition, and on the service handler class. What we attempt to do is call collect_operation_handler_factories. This visits all op methods, retrieves the Operation instance that was stashed there, and uses op.name as the key. But this Operation instance was created by sync_operation, and this should not have to know the override name provided in the service definition. In this case, what we want is the Operation instance inside the ServiceDefinition attached to the service handler class. --- src/nexusrpc/handler/_core.py | 64 +++++++++++++++++------------------ 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 0578956..659909e 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -48,40 +48,27 @@ def my_op(self, ...) 4. Handler.__init__ is called with [MyServiceHandler()] -5. A ServiceHandler instance is built from the user service handler class. The task - here is to build a map {op.name: OperationHandler} by calling factory functions. - The factory is attached to my_op. The key (op.name) is the key in the operations - map which is present in two places: on the service definition, and on the service - handler class. - What we attempt to do is call collect_operation_handler_factories. This visits - all op methods, retrieves the Operation instance that was stashed there, and uses - op.name as the key. But this Operation instance was created by sync_operation, and - this should not have to know the override name provided in the service definition. - - In this case, what we want is the Operation instance inside the ServiceDefinition - attached to the service handler class. - - [Incidentally, note that we already visited all the op methods in sync_operation, - in order to gather factories to create a service definition. When the service - definition is supplied, we don't need to do this, but currently we do, and we check - the collected factory input/output types against the service definition.] +5. 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. +6. 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. -5. The Handler does self.service_handlers[S], yielding an instance of ServiceHandler. +6. The Handler does self.service_handlers[S], yielding an instance of ServiceHandler. -6. The ServiceHandler does self.operation_handlers[O], yielding an instance of +7. 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. - - - +are keyed by the publicly advertised service and operation name respectively. This was achieved +at steps (6) and (5) respectively. Case 2: There exists a user service handler class without a corresonding service definition @@ -94,6 +81,11 @@ class MyServiceHandler: @sync_operation def my_op(...) +This follows Case 1 with the following differences: + +- Step (1) does not occur. +- At step (3) the ServiceDefinition is synthesized by the @service_handler decorator from + MyServiceHandler. """ from __future__ import annotations @@ -115,7 +107,7 @@ def my_op(...) from typing_extensions import Self import nexusrpc -from nexusrpc._util import get_service_definition +from nexusrpc._util import get_operation_definition, get_service_definition from nexusrpc.handler._util import is_async_callable from .. import OperationInfo @@ -487,14 +479,22 @@ def from_user_instance(cls, user_instance: Any) -> Self: f"Use the :py:func:`@nexusrpc.handler.service_handler` decorator on your class to define " f"a Nexus service implementation." ) - # Bug! If there's a service definition, name here must be taken from the - # Operation in that. - op_handlers = { - name: factory(user_instance) - for name, factory in collect_operation_handler_factories( - user_instance.__class__, service - ).items() - } + + factories_by_method_name = {} + for factory in collect_operation_handler_factories( + user_instance.__class__, service + ).values(): + op_defn = get_operation_definition(factory) + if not op_defn: + raise ValueError( + f"Operation handler factory {factory} does not have an operation definition." + ) + factories_by_method_name[op_defn.method_name] = factory + + op_handlers = {} + for op_name, op in service.operations.items(): + factory = factories_by_method_name[op.method_name] + op_handlers[op_name] = factory(user_instance) return cls( service=service, operation_handlers=op_handlers, From 02e60104d072a8a6478380a69ea4d4cffdfc169e Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 18:24:03 -0400 Subject: [PATCH 111/178] Bug fix 2 --- src/nexusrpc/handler/_decorators.py | 14 ++++-- src/nexusrpc/handler/_operation_handler.py | 52 +++++++++++++++------- tests/handler/test_request_routing.py | 6 +-- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index f8f8052..e595664 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -18,6 +18,7 @@ from nexusrpc import InputT, OutputT from nexusrpc._types import ServiceHandlerT from nexusrpc._util import ( + get_operation_definition, get_service_definition, set_operation_definition, set_operation_factory, @@ -122,15 +123,22 @@ def decorator(cls: Type[ServiceHandlerT]) -> Type[ServiceHandlerT]: raise ValueError("Service name must not be empty.") # Note: this is ignored if the service definition was supplied - op_factories = collect_operation_handler_factories(cls, _service) + factories_by_method_name = {} + for factory in collect_operation_handler_factories(cls, _service).values(): + op_defn = get_operation_definition(factory) + if not op_defn: + raise ValueError( + f"Operation handler factory {factory} does not have an operation definition." + ) + factories_by_method_name[op_defn.method_name] = factory # TODO(prerelease): if service defn was supplied then check that no operation # handler has attempted to set a conflicting name override. service = _service or service_definition_from_operation_handler_methods( - _name, op_factories + _name, factories_by_method_name ) - validate_operation_handler_methods(cls, op_factories, service) + validate_operation_handler_methods(cls, factories_by_method_name, service) set_service_definition(cls, service) return cls diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index de2ded6..89b4f19 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -239,15 +239,34 @@ def collect_operation_handler_factories( def validate_operation_handler_methods( user_service_cls: Type[ServiceHandlerT], - user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], + user_methods_by_method_name: dict[ + str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]] + ], service_definition: nexusrpc.ServiceDefinition, ) -> None: - """Validate operation handler methods against a service definition.""" - for op_name, op_defn in service_definition.operations.items(): - method = user_methods.get(op_name) + """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. + """ + for op_defn in service_definition.operations.values(): + if not op_defn.method_name: + raise RuntimeError( + f"Operation '{op_defn}' in service definition '{service_definition}' " + f"does not have a method name. " + ) + method = user_methods_by_method_name.get(op_defn.method_name) if not method: raise TypeError( - f"Service '{user_service_cls}' does not implement operation '{op_name}' in interface '{service_definition}'. " + 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}'." ) # TODO(prerelease): it should be guaranteed that `method` is a factory, so this next call should be unnecessary. method, method_op_defn = get_operation_factory(method) @@ -269,9 +288,10 @@ def validate_operation_handler_methods( ) ): raise TypeError( - f"Operation '{op_name}' in service '{user_service_cls}' has input type '{method_op_defn.input_type}', " - f"which is not compatible with the input type '{op_defn.input_type}' " - f" in interface '{service_definition.name}'. The input type must be the same as or a " + 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." ) @@ -283,19 +303,21 @@ def validate_operation_handler_methods( and not is_subtype(method_op_defn.output_type, op_defn.output_type) ): raise TypeError( - f"Operation '{op_name}' in service '{user_service_cls}' has output type '{method_op_defn.output_type}', " - f"which is not compatible with the output type '{op_defn.output_type}' in interface '{service_definition}'. " - f"The output type must be the same as or a subclass of the operation definition output type." + 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 service_definition.operations.keys() > user_methods.keys(): + if service_definition.operations.keys() > user_methods_by_method_name.keys(): raise TypeError( f"Service '{user_service_cls}' does not implement all operations in interface '{service_definition}'. " - f"Missing operations: {service_definition.operations.keys() - user_methods.keys()}" + f"Missing operations: {service_definition.operations.keys() - user_methods_by_method_name.keys()}" ) - if user_methods.keys() > service_definition.operations.keys(): + if user_methods_by_method_name.keys() > service_definition.operations.keys(): raise TypeError( f"Service '{user_service_cls}' implements more operations than the interface '{service_definition}'. " - f"Extra operations: {user_methods.keys() - service_definition.operations.keys()}" + f"Extra operations: {user_methods_by_method_name.keys() - service_definition.operations.keys()}" ) diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py index c462065..ba9046f 100644 --- a/tests/handler/test_request_routing.py +++ b/tests/handler/test_request_routing.py @@ -25,10 +25,10 @@ class _TestCase: class UserServiceHandler: async def op(self, ctx: StartOperationContext, input: None) -> bool: assert (service_defn := get_service_definition(self.__class__)) - _, op_defn = get_operation_factory(self.op) - assert op_defn assert ctx.service == service_defn.name - assert ctx.operation == op_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 From b8fe8f6b62f7eb3a3f0853f78a565ffad12df363 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 18:44:03 -0400 Subject: [PATCH 112/178] Test request routing without service definition --- tests/handler/test_request_routing.py | 91 +++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py index ba9046f..aeab858 100644 --- a/tests/handler/test_request_routing.py +++ b/tests/handler/test_request_routing.py @@ -18,7 +18,6 @@ class _TestCase: UserService: Type[Any] - # `expected` is (request, handler), where both request and handler are # (service_name, op_name) supported_request: tuple[str, str] @@ -47,7 +46,7 @@ async def op(self, ctx: StartOperationContext, input: None) -> bool: class OverrideServiceName(_TestCase): - @nexusrpc.service(name="UserServiceRenamed") + @nexusrpc.service(name="UserService-renamed") class UserService: op: nexusrpc.Operation[None, bool] @@ -57,7 +56,7 @@ class UserServiceHandler(_TestCase.UserServiceHandler): async def op(self, ctx: StartOperationContext, input: None) -> bool: return await super().op(ctx, input) - supported_request = ("UserServiceRenamed", "op") + supported_request = ("UserService-renamed", "op") class OverrideOperationName(_TestCase): @@ -74,17 +73,100 @@ async def op(self, ctx: StartOperationContext, input: None) -> bool: 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 super().op(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): +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 super().op(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 super().op(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 super().op(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 super().op(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, @@ -92,6 +174,7 @@ async def test_request_routing_with_service_definition(test_case: _TestCase): headers={}, request_id="request-id", ) + handler = Handler(user_service_handlers=[test_case.UserServiceHandler()]) result = await handler.start_operation( ctx, LazyValue(serializer=DummySerializer(None), headers={}) ) From fcad5b8a7e0bb1e3e5111472369eeebdbb16b3ae Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 19:15:46 -0400 Subject: [PATCH 113/178] Fix tests --- .../handler/test_service_handler_decorator_requirements.py | 7 +++++-- ...handler_decorator_validates_against_service_contract.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 3ed43c0..15da9af 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -33,7 +33,9 @@ class UserServiceHandler: @operation_handler def op_A(self) -> OperationHandler[int, str]: ... - expected_error_message_pattern = r"does not implement operation 'op_B'" + expected_error_message_pattern = ( + r"does not implement an operation with method name 'op_B'" + ) class MethodNameDoesNotMatchDefinition(_DecoratorValidationTestCase): @@ -167,7 +169,8 @@ def test_service_definition_inheritance_behavior( assert set(service_defn.operations) == test_case.expected_ops with pytest.raises( - TypeError, match="does not implement operation 'op_from_child_definition'" + TypeError, + match="does not implement an operation with method name 'op_from_child_definition'", ): @service_handler(service=test_case.UserService) 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 index 3a27e4c..df9ce3c 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -64,7 +64,7 @@ class Interface: class Impl: pass - error_message = "does not implement operation 'op'" + error_message = "does not implement an operation with method name 'op'" class MissingInputAnnotation(_InterfaceImplementationTestCase): From 68f1d0d91f68e25775baf9d87df5be48d7244ec5 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 20:25:53 -0400 Subject: [PATCH 114/178] Cleanup: collect_operation_handler_factories_by_method_name --- src/nexusrpc/handler/_core.py | 28 ++++++++----------- src/nexusrpc/handler/_decorators.py | 14 +++------- src/nexusrpc/handler/_operation_handler.py | 23 +++++++++------ ...rrectly_functioning_operation_factories.py | 4 +-- 4 files changed, 32 insertions(+), 37 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 659909e..757ab48 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -107,7 +107,7 @@ def my_op(...) from typing_extensions import Self import nexusrpc -from nexusrpc._util import get_operation_definition, get_service_definition +from nexusrpc._util import get_service_definition from nexusrpc.handler._util import is_async_callable from .. import OperationInfo @@ -124,7 +124,7 @@ def my_op(...) ) from ._operation_handler import ( OperationHandler, - collect_operation_handler_factories, + collect_operation_handler_factories_by_method_name, ) # TODO(preview): show what it looks like to manually build a service implementation at runtime @@ -480,21 +480,17 @@ def from_user_instance(cls, user_instance: Any) -> Self: f"a Nexus service implementation." ) - factories_by_method_name = {} - for factory in collect_operation_handler_factories( + # 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 - ).values(): - op_defn = get_operation_definition(factory) - if not op_defn: - raise ValueError( - f"Operation handler factory {factory} does not have an operation definition." - ) - factories_by_method_name[op_defn.method_name] = factory - - op_handlers = {} - for op_name, op in service.operations.items(): - factory = factories_by_method_name[op.method_name] - op_handlers[op_name] = factory(user_instance) + ) + 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, diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index e595664..e93bac4 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -18,7 +18,6 @@ from nexusrpc import InputT, OutputT from nexusrpc._types import ServiceHandlerT from nexusrpc._util import ( - get_operation_definition, get_service_definition, set_operation_definition, set_operation_factory, @@ -35,7 +34,7 @@ OperationHandler, SyncioSyncOperationHandler, SyncOperationHandler, - collect_operation_handler_factories, + collect_operation_handler_factories_by_method_name, service_definition_from_operation_handler_methods, validate_operation_handler_methods, ) @@ -123,14 +122,9 @@ def decorator(cls: Type[ServiceHandlerT]) -> Type[ServiceHandlerT]: raise ValueError("Service name must not be empty.") # Note: this is ignored if the service definition was supplied - factories_by_method_name = {} - for factory in collect_operation_handler_factories(cls, _service).values(): - op_defn = get_operation_definition(factory) - if not op_defn: - raise ValueError( - f"Operation handler factory {factory} does not have an operation definition." - ) - factories_by_method_name[op_defn.method_name] = factory + factories_by_method_name = collect_operation_handler_factories_by_method_name( + cls, _service + ) # TODO(prerelease): if service defn was supplied then check that no operation # handler has attempted to set a conflicting name override. diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 89b4f19..569f784 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -199,15 +199,15 @@ def cancel(self, ctx: CancelOperationContext, token: str) -> None: ) -def collect_operation_handler_factories( +def collect_operation_handler_factories_by_method_name( user_service_cls: Type[ServiceHandlerT], service: Optional[nexusrpc.ServiceDefinition], ) -> dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]]: """ Collect operation handler methods from a user service handler class. """ - factories = {} - op_defn_method_names = ( + factories: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]] = {} + service_method_names = ( { op.method_name for op in service.operations.values() @@ -216,24 +216,29 @@ def collect_operation_handler_factories( if service else set() ) + seen = set() for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): factory, op_defn = get_operation_factory(method) - if isinstance(op_defn, nexusrpc.Operation): + if factory and isinstance(op_defn, nexusrpc.Operation): # This is a method decorated with one of the *operation_handler decorators - if op_defn.name in factories: + 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 op_defn_method_names: - _names = ", ".join(f"'{s}'" for s in sorted(op_defn_method_names)) + if service and op_defn.method_name not in service_method_names: + _names = ", ".join(f"'{s}'" for s in sorted(service_method_names)) raise TypeError( 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: {_names}." ) - - factories[op_defn.name] = factory + # 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 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 index ac8a385..563559e 100644 --- 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 @@ -21,7 +21,7 @@ service_handler, sync_operation, ) -from nexusrpc.handler._core import collect_operation_handler_factories +from nexusrpc.handler._core import collect_operation_handler_factories_by_method_name from nexusrpc.handler._decorators import operation_handler from nexusrpc.handler._util import is_async_callable @@ -87,7 +87,7 @@ async def test_collected_operation_factories_match_service_definition( service = get_service_definition(test_case.Service) assert isinstance(service, nexusrpc.ServiceDefinition) assert service.name == "Service" - operation_factories = collect_operation_handler_factories( + operation_factories = collect_operation_handler_factories_by_method_name( test_case.Service, service ) assert operation_factories.keys() == test_case.expected_operation_factories.keys() From 8776f9d407a6b6ec93f94cc447fa1ddda3ecc51c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 22:28:28 -0400 Subject: [PATCH 115/178] Cleanups from code review - authors - dataclass pseudo-docstrings --- pyproject.toml | 2 +- src/nexusrpc/__init__.py | 20 +++++++--- src/nexusrpc/handler/_common.py | 68 ++++++++++++++++++++++----------- src/nexusrpc/handler/_core.py | 2 +- 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e7cff74..f36ff36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.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 = [ diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index bc7400d..933c026 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -22,11 +22,17 @@ class Link: It can be used to pass information about the caller to the handler, or vice versa. """ - # The URL must be percent-encoded. url: str - # Can describe an actual data type for decoding the URL. Valid chars: alphanumeric, '_', '.', - # '/' + """ + Link URL. Must be percent-encoded. + """ + type: str + """ + Can describe an data type for decoding the URL. + + Valid chars: alphanumeric, '_', '.', '/' + """ class OperationState(Enum): @@ -46,11 +52,15 @@ class OperationInfo: Information about an operation. """ - # Token identifying the operation (returned on operation start). token: str + """ + Token identifying the operation (returned on operation start). + """ - # The operation's state state: OperationState + """ + The operation's state. + """ class OperationErrorState(Enum): diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 9a756f1..2044c22 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -79,12 +79,20 @@ def __new__(cls, *args, **kwargs): ) return super().__new__(cls) - # The name of the service that the operation belongs to. service: str - # The name of the operation. + """ + The name of the service that the operation belongs to. + """ + operation: str - # Optional header fields sent by the caller. + """ + The name of the operation. + """ + headers: Mapping[str, str] + """ + Optional header fields sent by the caller. + """ @dataclass(frozen=True) @@ -93,27 +101,37 @@ class StartOperationContext(OperationContext): Includes information from the request.""" - # Request ID that may be used by the handler to dedupe a start request. - # By default a v4 UUID will be generated by the client. request_id: str + """ + Request ID that may be used by the handler to dedupe a start request. + By default a v4 UUID will be generated by the client. + """ - # 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_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. + """ - # Optional header fields set by the caller to be attached to the callback request when an - # asynchronous operation completes. 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. + """ - # 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. 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. + """ - # 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. 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) @@ -136,10 +154,12 @@ class FetchOperationResultContext(OperationContext): Includes information from the request.""" - # 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. 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. + """ # TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? @@ -150,19 +170,21 @@ class StartOperationResultSync(Generic[OutputT]): """ value: OutputT + """ + The value returned by the operation. + """ # TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? -# TODO(prelease) Demonstrate a type-safe fetch_result @dataclass(frozen=True) class StartOperationResultAsync: """ A value returned by the start method of a nexus operation handler indicating that the operation is responding asynchronously. - - It contains a token that the caller can submit with subsequent ``fetch_info``, - ``fetch_result``, or ``cancel`` requests. """ - # TODO(prerelease): string or OperationToken Python object? 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 index 757ab48..a1855b7 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -501,7 +501,7 @@ def _get_operation_handler(self, operation: str) -> OperationHandler[Any, Any]: if operation not in self.service.operations: msg = ( f"Nexus service definition '{self.service.name}' has no operation '{operation}'. " - f"There are {len(self.service.operations)} operations in the definition." + f"There are {len(self.service.operations)} operations in the definition" ) if self.service.operations: msg += f": {', '.join(sorted(self.service.operations.keys()))}" From 97f22e0ddbf4105c797e8b92b2270fd26771953d Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 22:35:59 -0400 Subject: [PATCH 116/178] Move HandlerError to root module --- src/nexusrpc/__init__.py | 54 +++++++++++++++++++++++++++ src/nexusrpc/handler/__init__.py | 2 - src/nexusrpc/handler/_common.py | 54 --------------------------- src/nexusrpc/handler/_core.py | 5 +-- tests/handler/test_async_operation.py | 10 +++-- 5 files changed, 62 insertions(+), 63 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 933c026..8904752 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from enum import Enum +from typing import Optional from ._serializer import Content as Content, LazyValue as LazyValue from ._service import ( @@ -80,3 +81,56 @@ class OperationError(Exception): def __init__(self, message: str, *, state: OperationErrorState): super().__init__(message) self.state = 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" + UNAUTHENTICATED = "UNAUTHENTICATED" + UNAUTHORIZED = "UNAUTHORIZED" + NOT_FOUND = "NOT_FOUND" + RESOURCE_EXHAUSTED = "RESOURCE_EXHAUSTED" + INTERNAL = "INTERNAL" + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + UNAVAILABLE = "UNAVAILABLE" + UPSTREAM_TIMEOUT = "UPSTREAM_TIMEOUT" + + +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. + """ + + def __init__( + self, + message: str, + *, + type: HandlerErrorType, + cause: Optional[BaseException] = None, + # Whether this error should be considered retryable. If not specified, retry + # behavior is determined from the error type. For example, INTERNAL is retryable + # by default unless specified otherwise. + retryable: Optional[bool] = None, + ): + """ + Initializes a new HandlerError. + + :param message: A descriptive message for the error. This will become the `message` + in the resulting Nexus Failure object. + :param type: The type of handler error. + :param cause: The original exception that caused this handler error, if any. + This will be encoded in the `details` of the Nexus Failure object. + :param retryable: Whether this error should be retried. If not + provided, the default behavior for the error type is used. + """ + super().__init__(message) + self.__cause__ = cause + self.type = type + self.retryable = retryable diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 3157d9c..0dff164 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -14,8 +14,6 @@ CancelOperationContext as CancelOperationContext, FetchOperationInfoContext as FetchOperationInfoContext, FetchOperationResultContext as FetchOperationResultContext, - HandlerError as HandlerError, - HandlerErrorType as HandlerErrorType, OperationContext as OperationContext, StartOperationContext as StartOperationContext, StartOperationResultAsync as StartOperationResultAsync, diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 2044c22..7e6a437 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -2,7 +2,6 @@ from dataclasses import dataclass, field from datetime import timedelta -from enum import Enum from typing import ( Generic, Mapping, @@ -13,59 +12,6 @@ from nexusrpc import Link, OutputT -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" - UNAUTHENTICATED = "UNAUTHENTICATED" - UNAUTHORIZED = "UNAUTHORIZED" - NOT_FOUND = "NOT_FOUND" - RESOURCE_EXHAUSTED = "RESOURCE_EXHAUSTED" - INTERNAL = "INTERNAL" - NOT_IMPLEMENTED = "NOT_IMPLEMENTED" - UNAVAILABLE = "UNAVAILABLE" - UPSTREAM_TIMEOUT = "UPSTREAM_TIMEOUT" - - -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. - """ - - def __init__( - self, - message: str, - *, - type: HandlerErrorType, - cause: Optional[BaseException] = None, - # Whether this error should be considered retryable. If not specified, retry - # behavior is determined from the error type. For example, INTERNAL is retryable - # by default unless specified otherwise. - retryable: Optional[bool] = None, - ): - """ - Initializes a new HandlerError. - - :param message: A descriptive message for the error. This will become the `message` - in the resulting Nexus Failure object. - :param type: The type of handler error. - :param cause: The original exception that caused this handler error, if any. - This will be encoded in the `details` of the Nexus Failure object. - :param retryable: Whether this error should be retried. If not - provided, the default behavior for the error type is used. - """ - super().__init__(message) - self.__cause__ = cause - self.type = type - self.retryable = retryable - - @dataclass(frozen=True) class OperationContext: """Context for the execution of the requested operation method. diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index a1855b7..e1a544d 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -107,17 +107,14 @@ def my_op(...) from typing_extensions import Self import nexusrpc +from nexusrpc import HandlerError, HandlerErrorType, LazyValue, OperationInfo from nexusrpc._util import get_service_definition from nexusrpc.handler._util import is_async_callable -from .. import OperationInfo -from .._serializer import LazyValue from ._common import ( CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, - HandlerError, - HandlerErrorType, StartOperationContext, StartOperationResultAsync, StartOperationResultSync, diff --git a/tests/handler/test_async_operation.py b/tests/handler/test_async_operation.py index c10eff2..c206af5 100644 --- a/tests/handler/test_async_operation.py +++ b/tests/handler/test_async_operation.py @@ -5,14 +5,18 @@ import pytest -from nexusrpc import LazyValue, OperationInfo, OperationState +from nexusrpc import ( + HandlerError, + HandlerErrorType, + LazyValue, + OperationInfo, + OperationState, +) from nexusrpc.handler import ( CancelOperationContext, FetchOperationInfoContext, FetchOperationResultContext, Handler, - HandlerError, - HandlerErrorType, OperationHandler, StartOperationContext, StartOperationResultAsync, From d5a94b6f91345577479aa73803032d6d4868d2a2 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Jun 2025 23:16:49 -0400 Subject: [PATCH 117/178] Create syncio version of @sync_operation, in syncio module --- src/nexusrpc/handler/__init__.py | 5 +- src/nexusrpc/handler/_core.py | 75 ------ src/nexusrpc/handler/_decorators.py | 68 ++---- src/nexusrpc/handler/_operation_handler.py | 50 +--- src/nexusrpc/handler/syncio.py | 225 ++++++++++++++++++ tests/handler/test_handler_syncio.py | 5 +- ...corator_creates_valid_operation_handler.py | 3 +- 7 files changed, 245 insertions(+), 186 deletions(-) create mode 100644 src/nexusrpc/handler/syncio.py diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 0dff164..f058ec7 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -19,10 +19,7 @@ StartOperationResultAsync as StartOperationResultAsync, StartOperationResultSync as StartOperationResultSync, ) -from ._core import ( - Handler as Handler, - SyncioHandler as SyncioHandler, -) +from ._core import Handler as Handler from ._decorators import ( service_handler as service_handler, sync_operation as sync_operation, diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index e1a544d..dc3ba6d 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -370,81 +370,6 @@ async def fetch_operation_result( # TODO(prerelease): we have a syncio module now housing the syncio version of # SyncOperationHandler. If we're retaining that then this (and an async version of # LazyValue) should go in there. -class SyncioHandler(BaseHandler): - """ - A Nexus handler with non-async `def` methods. - - A Nexus handler manages 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 start_operation( - self, - ctx: StartOperationContext, - input: LazyValue, - ) -> 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_sync(as_type=op.input_type) - - if is_async_callable(op_handler.start): - raise RuntimeError( - "Operation start handler method is an `async def` and " - "cannot be called from a sync handler. " - ) - # TODO(preview): apply middleware stack as composed functions - if not self.executor: - raise RuntimeError( - "Operation start handler method is not an `async def` but " - "no executor was provided to the Handler constructor. " - ) - 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) - if is_async_callable(op_handler.cancel): - raise RuntimeError( - "Operation cancel handler method is an `async def` and " - "cannot be called from a sync handler. " - ) - else: - if not self.executor: - raise RuntimeError( - "Operation cancel handler method is not an `async def` function 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 - - @dataclass(frozen=True) class ServiceHandler: """Internal representation of a user's Nexus service implementation instance. diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index e93bac4..a7e8c28 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -10,7 +10,6 @@ Type, TypeVar, Union, - cast, overload, ) @@ -27,12 +26,10 @@ from nexusrpc.handler._util import ( get_callable_name, get_start_method_input_and_output_type_annotations, - is_async_callable, ) from ._operation_handler import ( OperationHandler, - SyncioSyncOperationHandler, SyncOperationHandler, collect_operation_handler_factories_by_method_name, service_definition_from_operation_handler_methods, @@ -230,13 +227,9 @@ def decorator( @overload def sync_operation( start: Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], + [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] ], -) -> Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], -]: ... +) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: ... @overload @@ -244,44 +237,26 @@ def sync_operation( *, name: Optional[str] = None, ) -> Callable[ - [ - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ] - ], - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ], + [Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]], + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], ]: ... def sync_operation( start: Optional[ - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ] + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]] ] = None, *, name: Optional[str] = None, ) -> Union[ - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ], + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], Callable[ [ Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], + [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] ] ], - Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ], + Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]], ], ]: """ @@ -290,32 +265,17 @@ def sync_operation( def decorator( start: Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], + [ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT] ], - ) -> Callable[ - [ServiceHandlerT, StartOperationContext, InputT], - Union[OutputT, Awaitable[OutputT]], - ]: + ) -> Callable[[ServiceHandlerT, StartOperationContext, InputT], Awaitable[OutputT]]: def operation_handler_factory( self: ServiceHandlerT, ) -> OperationHandler[InputT, OutputT]: - if is_async_callable(start): - start_async = start - - async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: - return await start_async(self, ctx, input) - - _start.__doc__ = start.__doc__ - return SyncOperationHandler(_start) - else: - start_sync = cast(Callable[..., OutputT], start) - - def _start_sync(ctx: StartOperationContext, input: InputT) -> OutputT: - return start_sync(self, ctx, input) + async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: + return await start(self, ctx, input) - _start_sync.__doc__ = start.__doc__ - return SyncioSyncOperationHandler(_start_sync) + _start.__doc__ = start.__doc__ + return SyncOperationHandler(_start) input_type, output_type = get_start_method_input_and_output_type_annotations( start diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 569f784..9f93e0d 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -101,7 +101,7 @@ 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:`SyncioSyncOperationHandler`. + :py:class:`nexusrpc.handler.syncio.SyncOperationHandler`. """ def __init__( @@ -151,54 +151,6 @@ async def cancel(self, ctx: CancelOperationContext, token: str) -> None: ) -class SyncioSyncOperationHandler(OperationHandler[InputT, OutputT]): - """ - An :py:class:`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:`SyncOperationHandler`. - """ - - def __init__(self, start: Callable[[StartOperationContext, InputT], OutputT]): - if is_async_callable(start): - raise RuntimeError( - f"{start} is an `async def` method. " - "SyncioSyncOperationHandler must be initialized with a `def` method. " - "To use `async def` methods, use SyncOperationHandler." - ) - self._start = start - if start.__doc__: - self.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." - ) - - def collect_operation_handler_factories_by_method_name( user_service_cls: Type[ServiceHandlerT], service: Optional[nexusrpc.ServiceDefinition], diff --git a/src/nexusrpc/handler/syncio.py b/src/nexusrpc/handler/syncio.py new file mode 100644 index 0000000..32e283d --- /dev/null +++ b/src/nexusrpc/handler/syncio.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from typing import ( + Any, + Callable, + Optional, + Union, + overload, +) + +import nexusrpc +from nexusrpc import InputT, LazyValue, OperationInfo, OutputT +from nexusrpc._types import ServiceHandlerT +from nexusrpc._util import ( + set_operation_definition, + set_operation_factory, +) +from nexusrpc.handler._common import ( + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, +) +from nexusrpc.handler._core import BaseHandler +from nexusrpc.handler._util import ( + get_callable_name, + get_start_method_input_and_output_type_annotations, + is_async_callable, +) + +from ._operation_handler import OperationHandler + + +class Handler(BaseHandler): + """ + A Nexus handler with non-async `def` methods. + + A Nexus handler manages 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 start_operation( + self, + ctx: StartOperationContext, + input: LazyValue, + ) -> 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_sync(as_type=op.input_type) + + if is_async_callable(op_handler.start): + raise RuntimeError( + "Operation start handler method is an `async def` and " + "cannot be called from a sync handler. " + ) + # TODO(preview): apply middleware stack as composed functions + if not self.executor: + raise RuntimeError( + "Operation start handler method is not an `async def` but " + "no executor was provided to the Handler constructor. " + ) + 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) + if is_async_callable(op_handler.cancel): + raise RuntimeError( + "Operation cancel handler method is an `async def` and " + "cannot be called from a sync handler. " + ) + else: + if not self.executor: + raise RuntimeError( + "Operation cancel handler method is not an `async def` function 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 + + +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__: + self.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. + """ + + 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/handler/test_handler_syncio.py b/tests/handler/test_handler_syncio.py index 881a1cf..17ef73b 100644 --- a/tests/handler/test_handler_syncio.py +++ b/tests/handler/test_handler_syncio.py @@ -8,10 +8,9 @@ from nexusrpc.handler import ( StartOperationContext, StartOperationResultSync, - SyncioHandler, service_handler, - sync_operation, ) +from nexusrpc.handler.syncio import Handler, sync_operation class _TestCase: @@ -30,7 +29,7 @@ def incr(self, ctx: StartOperationContext, input: int) -> int: @pytest.mark.parametrize("test_case", [SyncHandlerHappyPath]) def test_sync_handler_happy_path(test_case: Type[_TestCase]): - handler = SyncioHandler( + handler = Handler( user_service_handlers=[test_case.user_service_handler], executor=ThreadPoolExecutor(max_workers=1), ) 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 index 706e72c..e665bfa 100644 --- 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 @@ -8,6 +8,7 @@ StartOperationResultSync, service_handler, sync_operation, + syncio, ) from nexusrpc.handler._util import is_async_callable @@ -17,7 +18,7 @@ class MyServiceHandler: def __init__(self): self.mutable_container = [] - @sync_operation + @syncio.sync_operation def my_def_op(self, ctx: StartOperationContext, input: int) -> int: """ This is the docstring for the `my_def_op` sync operation. From a607809dd014b6ca15696ef56935368eab5bcd46 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 08:48:18 -0400 Subject: [PATCH 118/178] Cleanup --- src/nexusrpc/_service.py | 4 +-- src/nexusrpc/_util.py | 9 ++++-- src/nexusrpc/handler/__init__.py | 6 ---- src/nexusrpc/handler/_core.py | 32 +++++-------------- .../test_service_definition_inheritance.py | 9 +++--- 5 files changed, 21 insertions(+), 39 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index 9b85840..f1505a7 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -151,10 +151,8 @@ def from_class( # already-decorated service definition. # If this class is decorated then return the already-computed ServiceDefinition. - # Do not use getattr since it would retrieve a value from a decorated parent class. if defn := get_service_definition(user_class): - if isinstance(defn, ServiceDefinition): - return defn + return defn if user_class is object: return ServiceDefinition(name=user_class.__name__, operations={}) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index 2eac321..4897e1b 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -18,9 +18,14 @@ def get_service_definition( # getattr would allow a non-decorated class to act as a service # definition if it inherits from a decorated class. if isinstance(obj, type): - return obj.__dict__.get("__nexus_service__") + defn = obj.__dict__.get("__nexus_service__") else: - return getattr(obj, "__dict__", {}).get("__nexus_service__") + 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( diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index f058ec7..db086dc 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -1,12 +1,6 @@ -# TODO(preview): show what it looks like to manually build a service implementation at runtime -# where the operations may be based on some runtime information. - # TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" # TODO(preview): pass mypy -# TODO(prerelease): docstrings -# TODO(prerelease): check API docs - from __future__ import annotations diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index dc3ba6d..7670745 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -71,7 +71,7 @@ def my_op(self, ...) at steps (6) and (5) respectively. -Case 2: There exists a user service handler class without a corresonding service definition +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 @@ -367,9 +367,6 @@ async def fetch_operation_result( return result -# TODO(prerelease): we have a syncio module now housing the syncio version of -# SyncOperationHandler. If we're retaining that then this (and an async version of -# LazyValue) should go in there. @dataclass(frozen=True) class ServiceHandler: """Internal representation of a user's Nexus service implementation instance. @@ -384,7 +381,7 @@ class ServiceHandler: 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 programatically creating Nexus service implementations. + constructor, for example when programmatically creating Nexus service implementations. """ service: nexusrpc.ServiceDefinition @@ -445,28 +442,15 @@ def _get_operation_handler(self, operation: str) -> OperationHandler[Any, Any]: return operation_handler -# TODO(prerelease): Do we want to require users to create this wrapper? Two -# alternatives: -# -# 1. Require them to pass in a `concurrent.futures.Executor`. This is what -# `run_in_executor` is documented to require. This would mean that nexusrpc would -# initially have a hard-coded dependency on the asyncio event loop. But perhaps that -# is not a problem: if we ever want to support other event loops, we can add the -# ability to pass in an event loop implementation at the level of Handler. And in -# fact perhaps that's better than having the user choose their event loop once in -# their Executor, and also in other places in nexusrpc. -# https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor -# -# 2. Define an interface (typing.Protocol), containing `def submit(...)` and perhaps -# nothing else, and require them to pass in anything that implements the interface. -# But this seems dangerous/a non-starter: run_in_executor is documented to require a -# `concurrent.futures.Executor`, even if it is currently typed as taking Any. -# -# I've switched to alternative (1). The following class is no longer in the public API -# of nexusrpc. 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 diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index 217d575..bb5444d 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -7,7 +7,8 @@ import pytest -from nexusrpc import Operation, ServiceDefinition, service +import nexusrpc +from nexusrpc import Operation, ServiceDefinition from nexusrpc._util import get_service_definition # See https://docs.python.org/3/howto/annotations.html @@ -94,7 +95,7 @@ class A1: expected_error = "Did you accidentally use '=' instead of ':'" -# TODO: test mro is honored: that synonymous operation definition in child class wins +# TODO(preview): test mro is honored: that synonymous operation definition in child class wins @pytest.mark.parametrize( "test_case", [ @@ -109,10 +110,10 @@ class A1: def test_user_service_definition_inheritance(test_case: Type[_TestCase]): if test_case.expected_error: with pytest.raises(Exception, match=test_case.expected_error): - service(test_case.UserService) + nexusrpc.service(test_case.UserService) return - service_defn = get_service_definition(service(test_case.UserService)) + 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(): From f474c0390aae07b9a22cf4b0b9cb7cbac917cefd Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 10:41:30 -0400 Subject: [PATCH 119/178] Service definition: only inherit operations from decorated classes --- src/nexusrpc/_service.py | 94 ++++++++----------- .../test_service_definition_inheritance.py | 5 + 2 files changed, 45 insertions(+), 54 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index f1505a7..2ffa809 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -144,29 +144,48 @@ def from_class( ) -> ServiceDefinition: """Create a ServiceDefinition from a user service definition class. - All parent classes contribute operations to the ServiceDefinition, whether or not - they are decorated with @nexusrpc.service. + 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. """ - # Recursively walk mro collecting operations not previously seen, stopping at an - # already-decorated service definition. - - # If this class is decorated then return the already-computed ServiceDefinition. - if defn := get_service_definition(user_class): - return defn - - if user_class is object: - return ServiceDefinition(name=user_class.__name__, operations={}) - - parent = user_class.mro()[1] - parent_defn = ServiceDefinition.from_class(parent, parent.__name__) - - # Update the inherited operations with those collected at this level. - defn = ServiceDefinition( - name=name, - operations=ServiceDefinition._merge_operations( - parent_defn.operations, user_class - ), + 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 + + defn = ServiceDefinition(name=name, operations=operations) if errors := defn._validation_errors(): raise ValueError( f"Service definition {name} has validation errors: {', '.join(errors)}" @@ -181,39 +200,6 @@ def _validation_errors(self) -> list[str]: errors.extend(op._validation_errors()) return errors - @staticmethod - def _merge_operations( - parent_operations: Mapping[str, Operation[Any, Any]], - user_class: Type[ServiceDefinitionT], - ) -> dict[str, Operation[Any, Any]]: - merged = dict(parent_operations) - parent_ops_by_method_name = {op.method_name: op for op in merged.values()} - for op_name, op in ServiceDefinition._collect_operations(user_class).items(): - # If the operation at this level derives from an annotation alone (no - # accompanying instance), then merge information from the inherited - # operation, as long as it doesn't conflict. We look up by method name; if - # the op at this level derives from an annotation alone then it has not - # overridden its name. - if parent_op := parent_ops_by_method_name.get(op_name): - if op_name not in user_class.__dict__: - # TODO(prerelease): what about if they are both type annotations? Then the later one should win. - if op.input_type != parent_op.input_type: - raise TypeError( - f"Operation '{op_name}' in class '{user_class}' has input_type " - f"({op.input_type}). This does not match the type of the same " - f"operation in a parent class: ({parent_op.input_type})." - ) - if op.output_type != parent_op.output_type: - raise TypeError( - f"Operation '{op_name}' in class '{user_class}' has output_type ({op.output_type}). " - f"This does not match the type of the same operation in a parent class: ({parent_op.output_type})." - ) - else: - merged[op_name] = parent_op - else: - merged[op_name] = op - return merged - @staticmethod def _collect_operations( user_class: Type[ServiceDefinitionT], diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index bb5444d..a90a00f 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -21,6 +21,7 @@ class _TestCase: class TypeAnnotationsOnly(_TestCase): + @nexusrpc.service class A1: a: Operation[int, str] @@ -32,6 +33,7 @@ class A2(A1): class TypeAnnotationsWithValues(_TestCase): + @nexusrpc.service class A1: a: Operation[int, str] = Operation[int, str](name="a-name") @@ -45,6 +47,7 @@ class A2(A1): 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") @@ -57,6 +60,7 @@ class A2(A1): class TypeAnnotationWithInheritedInstance(_TestCase): + @nexusrpc.service class A1: a: Operation[int, str] = Operation[int, str](name="a-name") @@ -86,6 +90,7 @@ class A1: class ChildClassSynthesizedWithTypeValues(_TestCase): + @nexusrpc.service class A1: a: Operation[int, str] From 62193e63818b878f25cb587c87a8f8ddd1e4b7fd Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 11:14:04 -0400 Subject: [PATCH 120/178] pydoctor --- pyproject.toml | 3 ++ src/nexusrpc/handler/_core.py | 52 ++++++++++++++++++----------------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f36ff36..4259bdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,3 +38,6 @@ target-version = "py39" [tool.ruff.lint.isort] combine-as-imports = true + +[tool.pydoctor] +docformat = "google" diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 7670745..de17a51 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -8,21 +8,21 @@ 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 +I.e., there are service definitions that look like:: -@service -class MyServiceDefinition: - my_op: nexusrpc.Operation[I, O] + @service + class MyServiceDefinition: + my_op: nexusrpc.Operation[I, O] -and every service handler class looks like +and every service handler class looks like:: -@service_handler(service=MyServiceDefinition) -class MyServiceHandler: - @sync_operation - def my_op(self, ...) + @service_handler(service=MyServiceDefinition) + class MyServiceHandler: + @sync_operation + def my_op(self, ...) Import time @@ -46,14 +46,14 @@ def my_op(self, ...) Handler-registration time ------------------------- -4. Handler.__init__ is called with [MyServiceHandler()] +1. Handler.__init__ is called with [MyServiceHandler()] -5. A ServiceHandler instance is built from the user service handler class. This comprises a +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. -6. Finally we build a map {service_definition.name: ServiceHandler} using the service definition +3. Finally we build a map {service_definition.name: ServiceHandler} using the service definition in each ServiceHandler. Request-handling time @@ -61,27 +61,27 @@ def my_op(self, ...) Now suppose a request has arrived for service S and operation O. -6. The Handler does self.service_handlers[S], yielding an instance of ServiceHandler. +1. The Handler does self.service_handlers[S], yielding an instance of ServiceHandler. -7. The ServiceHandler does self.operation_handlers[O], yielding an instance of +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 (6) and (5) respectively. +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 +I.e., at least one user service handler class looks like:: -@service_handler -class MyServiceHandler: - @sync_operation - def my_op(...) + @service_handler + class MyServiceHandler: + @sync_operation + def my_op(...) -This follows Case 1 with the following differences: +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 @@ -174,11 +174,13 @@ def __init__( for op_name, operation_handler in sh.operation_handlers.items(): if not is_async_callable(operation_handler.start): raise RuntimeError( - f"Service '{sh.service.name}' operation '{op_name}' start method must be an `async def` if no executor is provided." + f"Service '{sh.service.name}' operation '{op_name}' " + "start method must be an `async def` if no executor is provided." ) if not is_async_callable(operation_handler.cancel): raise RuntimeError( - f"Service '{sh.service.name}' operation '{op_name}' cancel method must be an `async def` if no executor is provided." + f"Service '{sh.service.name}' operation '{op_name}' " + "cancel method must be an `async def` if no executor is provided." ) self.service_handlers[sh.service.name] = sh From 5bec1509b4548f40f9b740398eabeca45104d768 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 11:31:02 -0400 Subject: [PATCH 121/178] make pyright pass uv run pyright --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- src/nexusrpc/_util.py | 2 +- src/nexusrpc/handler/_core.py | 8 +++++-- tests/handler/test_request_routing.py | 22 ++++++++++--------- ...collects_expected_operation_definitions.py | 2 +- ...test_service_handler_from_user_instance.py | 8 ++----- tests/test_get_input_and_output_types.py | 4 ++-- uv.lock | 8 +++---- 9 files changed, 30 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c20bd3c..a3b476d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: Type check # TODO(preview): Get both passing run: | - uv run pyright . || true + uv run pyright . uv run mypy --check-untyped-defs . || true - name: Run tests diff --git a/pyproject.toml b/pyproject.toml index 4259bdd..b389530 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ dev = [ "mypy>=1.15.0", "pydoctor>=25.4.0", - "pyright>=1.1.400", + "pyright>=1.1.402", "pytest>=8.3.5", "pytest-asyncio>=0.26.0", "pytest-cov>=6.1.1", diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index 4897e1b..cfabed1 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -183,7 +183,7 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False): if unwrap is not None: while True: if hasattr(unwrap, "__wrapped__"): - unwrap = unwrap.__wrapped__ + unwrap = unwrap.__wrapped__ # type: ignore continue if isinstance(unwrap, functools.partial): unwrap = unwrap.func diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index de17a51..4b6f2b8 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -202,8 +202,12 @@ def start_operation( ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, - Awaitable[StartOperationResultSync[Any]], - Awaitable[StartOperationResultAsync], + Awaitable[ + Union[ + StartOperationResultSync[Any], + StartOperationResultAsync, + ] + ], ]: ... @abstractmethod diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py index aeab858..36a3ef0 100644 --- a/tests/handler/test_request_routing.py +++ b/tests/handler/test_request_routing.py @@ -1,4 +1,4 @@ -from typing import Any, Type, cast +from typing import Any, Callable, Type, cast import pytest @@ -22,7 +22,9 @@ class _TestCase: supported_request: tuple[str, str] class UserServiceHandler: - async def op(self, ctx: StartOperationContext, input: None) -> bool: + 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) @@ -40,7 +42,7 @@ class UserService: class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: - return await super().op(ctx, input) + return await self._op_impl(ctx, input) supported_request = ("UserService", "op") @@ -54,7 +56,7 @@ class UserService: class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: - return await super().op(ctx, input) + return await self._op_impl(ctx, input) supported_request = ("UserService-renamed", "op") @@ -68,7 +70,7 @@ class UserService: class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: - return await super().op(ctx, input) + return await self._op_impl(ctx, input) supported_request = ("UserService", "op-renamed") @@ -82,7 +84,7 @@ class UserService: class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: - return await super().op(ctx, input) + return await self._op_impl(ctx, input) supported_request = ("UserService-renamed", "op-renamed") @@ -119,7 +121,7 @@ class NoServiceDefinitionNoOverrides(_TestCase): class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: - return await super().op(ctx, input) + return await self._op_impl(ctx, input) supported_request = ("UserServiceHandler", "op") @@ -129,7 +131,7 @@ class NoServiceDefinitionOverrideServiceName(_TestCase): class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation async def op(self, ctx: StartOperationContext, input: None) -> bool: - return await super().op(ctx, input) + return await self._op_impl(ctx, input) supported_request = ("UserServiceHandler-renamed", "op") @@ -139,7 +141,7 @@ class NoServiceDefinitionOverrideOperationName(_TestCase): class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation(name="op-renamed") async def op(self, ctx: StartOperationContext, input: None) -> bool: - return await super().op(ctx, input) + return await self._op_impl(ctx, input) supported_request = ("UserServiceHandler", "op-renamed") @@ -149,7 +151,7 @@ class NoServiceDefinitionOverrideServiceAndOperationName(_TestCase): class UserServiceHandler(_TestCase.UserServiceHandler): @sync_operation(name="op-renamed") async def op(self, ctx: StartOperationContext, input: None) -> bool: - return await super().op(ctx, input) + return await self._op_impl(ctx, input) supported_request = ("UserServiceHandler-renamed", "op-renamed") 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 index 75fb59e..505e1ac 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -32,7 +32,7 @@ class Output: class _TestCase: Service: Type[Any] expected_operations: dict[str, nexusrpc.Operation] - Contract: Optional[Type[nexusrpc.ServiceDefinition]] = None + Contract: Optional[Type[Any]] = None skip: Optional[str] = None diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py index 1219f29..7bd4bb0 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,10 +1,6 @@ from __future__ import annotations -from nexusrpc.handler import ( - StartOperationContext, - service_handler, - sync_operation, -) +from nexusrpc.handler import StartOperationContext, service_handler, syncio from nexusrpc.handler._core import ServiceHandler # TODO(preview): test operation_handler version of this @@ -21,7 +17,7 @@ def __call__( ) -> int: return input - sync_operation_with_callable_instance = sync_operation( + sync_operation_with_callable_instance = syncio.sync_operation( name="sync_operation_with_callable_instance", )( SyncOperationWithCallableInstance(), diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index c846988..5b32602 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -2,6 +2,7 @@ from typing import ( Any, Awaitable, + Callable, Type, Union, get_args, @@ -25,8 +26,7 @@ class Output: class _TestCase: - async def start(self, ctx: StartOperationContext, i: Input) -> Output: ... - + start: Callable[..., Any] expected_types: tuple[Any, Any] diff --git a/uv.lock b/uv.lock index 1ff54f7..dccdee0 100644 --- a/uv.lock +++ b/uv.lock @@ -478,7 +478,7 @@ requires-dist = [{ name = "typing-extensions", specifier = ">=4.12.2" }] dev = [ { name = "mypy", specifier = ">=1.15.0" }, { name = "pydoctor", specifier = ">=25.4.0" }, - { name = "pyright", specifier = ">=1.1.400" }, + { 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" }, @@ -554,15 +554,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.400" +version = "1.1.402" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/cb/c306618a02d0ee8aed5fb8d0fe0ecfed0dbf075f71468f03a30b5f4e1fe0/pyright-1.1.400.tar.gz", hash = "sha256:b8a3ba40481aa47ba08ffb3228e821d22f7d391f83609211335858bf05686bdb", size = 3846546, upload-time = "2025-04-24T12:55:18.907Z" } +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/c8/a5/5d285e4932cf149c90e3c425610c5efaea005475d5f96f1bfdb452956c62/pyright-1.1.400-py3-none-any.whl", hash = "sha256:c80d04f98b5a4358ad3a35e241dbf2a408eee33a40779df365644f8054d2517e", size = 5563460, upload-time = "2025-04-24T12:55:17.002Z" }, + { 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]] From 4b475d3d742ee0797db8897df3d78c594448e74e Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 12:09:09 -0400 Subject: [PATCH 122/178] Make mypy pass uv run mypy --check-untyped-defs . --- .github/workflows/ci.yml | 3 +-- pyproject.toml | 3 +++ src/nexusrpc/_util.py | 6 +++--- src/nexusrpc/handler/_common.py | 2 +- src/nexusrpc/handler/_decorators.py | 4 ++-- src/nexusrpc/handler/_operation_handler.py | 5 +++-- src/nexusrpc/handler/syncio.py | 3 ++- ...ler_decorator_collects_expected_operation_definitions.py | 4 ++-- ...ervice_handler_decorator_selects_correct_service_name.py | 3 ++- 9 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3b476d..e7ad1a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,10 +33,9 @@ jobs: uv run ruff check - name: Type check - # TODO(preview): Get both passing run: | uv run pyright . - uv run mypy --check-untyped-defs . || true + uv run mypy --check-untyped-defs . - name: Run tests run: | diff --git a/pyproject.toml b/pyproject.toml index b389530..9890f1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ packages = ["src/nexusrpc"] [tool.pyright] include = ["src", "tests"] +[tool.mypy] +disable_error_code = ["empty-body"] + [tool.ruff] target-version = "py39" diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index cfabed1..be4be2b 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -88,7 +88,7 @@ def set_operation_factory( import types # This is inspect.get_annotations from Python 3.13.5 - def get_annotations(obj, *, globals=None, locals=None, eval_str=False): + 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. @@ -186,11 +186,11 @@ def get_annotations(obj, *, globals=None, locals=None, eval_str=False): unwrap = unwrap.__wrapped__ # type: ignore continue if isinstance(unwrap, functools.partial): - unwrap = unwrap.func + unwrap = unwrap.func # type: ignore continue break if hasattr(unwrap, "__globals__"): - obj_globals = unwrap.__globals__ + obj_globals = unwrap.__globals__ # type: ignore if globals is None: globals = obj_globals diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 7e6a437..078fe04 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -115,7 +115,7 @@ class StartOperationResultSync(Generic[OutputT]): A result returned synchronously by the start method of a nexus operation handler. """ - value: OutputT + value: OutputT # type: ignore[misc] """ The value returned by the operation. """ diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index a7e8c28..0dfbb43 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -277,8 +277,8 @@ async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: _start.__doc__ = start.__doc__ return SyncOperationHandler(_start) - input_type, output_type = get_start_method_input_and_output_type_annotations( - 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) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 9f93e0d..79967b2 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -115,7 +115,8 @@ def __init__( ) self._start = start if start.__doc__: - self.start.__func__.__doc__ = start.__doc__ + if start_func := getattr(self.start, "__func__", None): + start_func.__doc__ = start.__doc__ async def start( self, ctx: StartOperationContext, input: InputT @@ -170,7 +171,7 @@ def collect_operation_handler_factories_by_method_name( ) seen = set() for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): - factory, op_defn = get_operation_factory(method) + factory, op_defn = get_operation_factory(method) # type: ignore[var-annotated] if factory and isinstance(op_defn, nexusrpc.Operation): # This is a method decorated with one of the *operation_handler decorators if op_defn.name in seen: diff --git a/src/nexusrpc/handler/syncio.py b/src/nexusrpc/handler/syncio.py index 32e283d..eae0f1d 100644 --- a/src/nexusrpc/handler/syncio.py +++ b/src/nexusrpc/handler/syncio.py @@ -130,7 +130,8 @@ def __init__(self, start: Callable[[StartOperationContext, InputT], OutputT]): ) self._start = start if start.__doc__: - self.start.__func__.__doc__ = start.__doc__ + if start_func := getattr(self.start, "__func__", None): + start_func.__doc__ = start.__doc__ def start( self, ctx: StartOperationContext, input: InputT 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 index 505e1ac..5b4c425 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -157,7 +157,7 @@ class Contract: @service_handler(service=Contract) class Service: - class sync_operation_with_callable_instance: + class _sync_operation_with_callable_instance: def __call__( self, _handler: Any, @@ -169,7 +169,7 @@ def __call__( # callable class itself, because the user must be responsible for instantiating # the class to obtain the callable instance. sync_operation_with_callable_instance = operation_handler( - sync_operation_with_callable_instance() # type: ignore + _sync_operation_with_callable_instance() # type: ignore ) expected_operations = { 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 index ea8846c..a57874a 100644 --- a/tests/handler/test_service_handler_decorator_selects_correct_service_name.py +++ b/tests/handler/test_service_handler_decorator_selects_correct_service_name.py @@ -86,4 +86,5 @@ def test_name_must_not_be_empty(): def test_name_and_interface_are_mutually_exclusive(): with pytest.raises(ValueError): - service_handler(name="my-service-impl-🌈", service=ServiceInterface) # type: ignore (enforced by overloads) + # Type error due to deliberately violating overload + service_handler(name="my-service-impl-🌈", service=ServiceInterface) # type: ignore From 42c0f7573a40d5bad0bb915ee21f7f80965100f5 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 12:26:07 -0400 Subject: [PATCH 123/178] Add poe tasks Switch to uv when it has something https://github.com/astral-sh/uv/issues/5903 --- .github/workflows/ci.yml | 13 ++----- pyproject.toml | 16 +++++++++ uv.lock | 78 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7ad1a9..29876e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,15 +27,8 @@ jobs: run: | uv sync - - name: Lint - run: | - uv run ruff format --check - uv run ruff check - - - name: Type check - run: | - uv run pyright . - uv run mypy --check-untyped-defs . + - name: Lint and type check + run: uv run poe lint - name: Run tests run: | @@ -48,7 +41,7 @@ jobs: path: coverage_html_report/ - name: Build API docs - run: uv run pydoctor src/nexusrpc + run: uv run poe docs - name: Deploy prod API docs if: ${{ github.ref == 'refs/heads/main' }} diff --git a/pyproject.toml b/pyproject.toml index 9890f1b..2bc1d7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ [dependency-groups] dev = [ "mypy>=1.15.0", + "poethepoet>=0.35.0", "pydoctor>=25.4.0", "pyright>=1.1.402", "pytest>=8.3.5", @@ -30,6 +31,21 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/nexusrpc"] +[tool.poe.tasks] +lint = [ + {cmd = "uv run pyright"}, + {cmd = "uv run mypy --check-untyped-defs ."}, + {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"] diff --git a/uv.lock b/uv.lock index dccdee0..9d7b57a 100644 --- a/uv.lock +++ b/uv.lock @@ -462,6 +462,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "poethepoet" }, { name = "pydoctor" }, { name = "pyright" }, { name = "pytest" }, @@ -477,6 +478,7 @@ 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" }, @@ -504,6 +506,15 @@ 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" @@ -522,6 +533,20 @@ 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" @@ -621,6 +646,59 @@ 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" From 914f507c406d0202d797335c971c89bae644263a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 12:41:04 -0400 Subject: [PATCH 124/178] CI os/python matrix --- .github/workflows/ci.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29876e6..065cf1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,17 @@ name: CI on: - push: - branches: [ main, v0 ] pull_request: + push: branches: [ main ] jobs: - test: - # TODO(preview): other platforms - runs-on: ubuntu-latest + lint-test-docs: + runs-on: ${{ matrix.os }} strategy: matrix: python-version: ['3.9', '3.13', '3.14'] + os: [ubuntu-latest, ubuntu-arm, macos-intel, macos-arm, windows-latest] steps: - name: Checkout repository @@ -37,7 +36,7 @@ jobs: - name: Upload coverage report uses: actions/upload-artifact@v4 with: - name: coverage-html-report-${{ matrix.python-version }} + name: coverage-html-report-${{ matrix.os }}-${{ matrix.python-version }} path: coverage_html_report/ - name: Build API docs From 3c6f318839af59212b5530d02b567df93de77be7 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 13:02:14 -0400 Subject: [PATCH 125/178] Cleanup --- src/nexusrpc/handler/_core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 4b6f2b8..5cd94a5 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -99,6 +99,7 @@ def my_op(...) Any, Awaitable, Callable, + Mapping, Optional, Sequence, Union, @@ -154,7 +155,7 @@ def __init__( 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: dict[str, ServiceHandler] = {} + self.service_handlers: Mapping[str, ServiceHandler] = {} for sh in user_service_handlers: if isinstance(sh, type): raise TypeError( From bf51ca1ae8a40b079f5f4072f859ac1880a71e08 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 13:04:44 -0400 Subject: [PATCH 126/178] AbstractHandler base class --- src/nexusrpc/handler/_core.py | 110 +++++++++++++++++---------------- src/nexusrpc/handler/syncio.py | 4 +- 2 files changed, 58 insertions(+), 56 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 5cd94a5..dbcecab 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -132,7 +132,61 @@ def my_op(...) # TODO(preview): pass mypy -class BaseHandler(ABC): +class AbstractHandler(ABC): + @abstractmethod + def start_operation( + self, + ctx: StartOperationContext, + input: LazyValue, + ) -> 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. @@ -195,60 +249,8 @@ def _get_service_handler(self, service_name: str) -> ServiceHandler: ) return service - @abstractmethod - def start_operation( - self, - ctx: StartOperationContext, - input: LazyValue, - ) -> 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 Handler(BaseHandler): +class Handler(BaseServiceCollectionHandler): """ A Nexus handler manages a collection of Nexus service handlers. diff --git a/src/nexusrpc/handler/syncio.py b/src/nexusrpc/handler/syncio.py index eae0f1d..dbe7f83 100644 --- a/src/nexusrpc/handler/syncio.py +++ b/src/nexusrpc/handler/syncio.py @@ -23,7 +23,7 @@ StartOperationResultAsync, StartOperationResultSync, ) -from nexusrpc.handler._core import BaseHandler +from nexusrpc.handler._core import BaseServiceCollectionHandler from nexusrpc.handler._util import ( get_callable_name, get_start_method_input_and_output_type_annotations, @@ -33,7 +33,7 @@ from ._operation_handler import OperationHandler -class Handler(BaseHandler): +class Handler(BaseServiceCollectionHandler): """ A Nexus handler with non-async `def` methods. From 3e736eeb6b41c3141c7c58a1e303843e65ae30a8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 13:24:20 -0400 Subject: [PATCH 127/178] Don't leak operation names on NOT_FOUND --- src/nexusrpc/handler/_core.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index dbcecab..715a2a2 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -427,27 +427,20 @@ def from_user_instance(cls, user_instance: Any) -> Self: 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: - msg = ( - f"Nexus service definition '{self.service.name}' has no operation '{operation}'. " - f"There are {len(self.service.operations)} operations in the definition" + 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, ) - if self.service.operations: - msg += f": {', '.join(sorted(self.service.operations.keys()))}" - msg += "." - raise HandlerError(msg, type=HandlerErrorType.NOT_FOUND) operation_handler = self.operation_handlers.get(operation) if operation_handler is None: - # This should not be possible. If a service definition was supplied then - # this was checked; if not then the definition was generated from the - # operation handlers. - msg = ( - f"Nexus service implementation '{self.service.name}' has no handler for operation '{operation}'. " - f"There are {len(self.operation_handlers)} available operation handlers" + 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, ) - if self.operation_handlers: - msg += f": {', '.join(sorted(self.operation_handlers.keys()))}" - msg += "." - raise HandlerError(msg, type=HandlerErrorType.NOT_FOUND) return operation_handler From b977ba507ea5172eeb4215ec27f16d61cbae5e74 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 13:57:46 -0400 Subject: [PATCH 128/178] Tweak error message --- src/nexusrpc/handler/_operation_handler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 79967b2..dad04a1 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -181,11 +181,15 @@ def collect_operation_handler_factories_by_method_name( ) if service and op_defn.method_name not in service_method_names: _names = ", ".join(f"'{s}'" for s in sorted(service_method_names)) - raise TypeError( + 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: {_names}." + 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." From f74d2ab470cdd0982e49595228c0786da3ed0782 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 14:04:21 -0400 Subject: [PATCH 129/178] RuntimeError -> ValueError --- src/nexusrpc/handler/_operation_handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index dad04a1..f36ba25 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -219,7 +219,7 @@ def validate_operation_handler_methods( """ for op_defn in service_definition.operations.values(): if not op_defn.method_name: - raise RuntimeError( + raise ValueError( f"Operation '{op_defn}' in service definition '{service_definition}' " f"does not have a method name. " ) @@ -233,7 +233,7 @@ def validate_operation_handler_methods( # TODO(prerelease): it should be guaranteed that `method` is a factory, so this next call should be unnecessary. method, method_op_defn = get_operation_factory(method) if not isinstance(method_op_defn, nexusrpc.Operation): - raise RuntimeError( + 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 " @@ -299,7 +299,7 @@ def service_definition_from_operation_handler_methods( for name, method in user_methods.items(): _, op_defn = get_operation_factory(method) if not isinstance(op_defn, nexusrpc.Operation): - raise RuntimeError( + 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 " From 5ace993b09479871fd6ab75cbb73adfd1d9cba8f Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 13:59:50 -0400 Subject: [PATCH 130/178] Test invalid usage --- src/nexusrpc/handler/_decorators.py | 9 -- src/nexusrpc/handler/_operation_handler.py | 13 ++- tests/handler/test_invalid_usage.py | 108 +++++++++++++++++++++ 3 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 tests/handler/test_invalid_usage.py diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 0dfbb43..c6130c0 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -110,27 +110,18 @@ class name will be used. def decorator(cls: Type[ServiceHandlerT]) -> Type[ServiceHandlerT]: # The name by which the service must be addressed in Nexus requests. - - # Note: this is ignored if the service definition was supplied _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.") - - # Note: this is ignored if the service definition was supplied factories_by_method_name = collect_operation_handler_factories_by_method_name( cls, _service ) - - # TODO(prerelease): if service defn was supplied then check that no operation - # handler has attempted to set a conflicting name override. - 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 diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index f36ba25..a8ab043 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -239,6 +239,13 @@ def validate_operation_handler_methods( 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 @@ -271,11 +278,7 @@ def validate_operation_handler_methods( f" '{service_definition}'. The output type must be the same as or a " f"subclass of the operation definition output type." ) - if service_definition.operations.keys() > user_methods_by_method_name.keys(): - raise TypeError( - f"Service '{user_service_cls}' does not implement all operations in interface '{service_definition}'. " - f"Missing operations: {service_definition.operations.keys() - user_methods_by_method_name.keys()}" - ) + if user_methods_by_method_name.keys() > service_definition.operations.keys(): raise TypeError( f"Service '{user_service_cls}' implements more operations than the interface '{service_definition}'. " diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py new file mode 100644 index 0000000..53d9bdb --- /dev/null +++ b/tests/handler/test_invalid_usage.py @@ -0,0 +1,108 @@ +""" +Tests for invalid ways that users may attempt to write service definition and service +handler implementations. +""" + +from typing import Any, Callable + +import pytest + +import nexusrpc +from nexusrpc.handler import StartOperationContext, service_handler, sync_operation + + +class _TestCase: + build: Callable[..., Any] + error_message: str + + +class OperationHandlerOverridesNameInconsistentlyWithServiceDefinition(_TestCase): + @staticmethod + def build(): + @nexusrpc.service + class S: + my_op: nexusrpc.Operation[None, None] + + @service_handler(service=S) + class H: + @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 S: + my_op_1: nexusrpc.Operation[None, None] + my_op_2: nexusrpc.Operation[None, None] + + @service_handler(service=S) + class H: + @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 S: + my_op_1: nexusrpc.Operation[None, None] + + @service_handler(service=S) + class H: + @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): + # TODO(preview): this seems wrong: why is no operation registered in the service definition? + # This is not an error currently + @staticmethod + def build(): + @nexusrpc.service + class S: + my_op: nexusrpc.Operation + + @service_handler(service=S) + class H: + @sync_operation + async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... + + error_message = ( + r"does not match an operation method name in the service definition. " + r"Available method names in the service definition: \[none\]." + ) + + +@pytest.mark.parametrize( + "test_case", + [ + OperationHandlerOverridesNameInconsistentlyWithServiceDefinition, + ServiceDefinitionOperationHasNoTypeParams, + ServiceDefinitionHasExtraOp, + ServiceHandlerHasExtraOp, + ], +) +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() From fb2eb4a3c4695c580249cc33e870a3cafeeee4ba Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 14:44:49 -0400 Subject: [PATCH 131/178] Cleanup --- src/nexusrpc/_service.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index 2ffa809..5c969ea 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -122,6 +122,10 @@ def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: # 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) From 56306b1135ad7dec237d7f4596318719a6f368d2 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 14:54:35 -0400 Subject: [PATCH 132/178] Bug fix: get error when operation definition lacks type params --- src/nexusrpc/_service.py | 2 +- tests/handler/test_invalid_usage.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index 5c969ea..8443fed 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -229,7 +229,7 @@ def _collect_operations( annotations = { k: v for k, v in get_annotations(user_class, eval_str=True).items() - if typing.get_origin(v) == Operation + 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 diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py index 53d9bdb..0b9e972 100644 --- a/tests/handler/test_invalid_usage.py +++ b/tests/handler/test_invalid_usage.py @@ -72,8 +72,6 @@ async def my_op_2( class ServiceDefinitionOperationHasNoTypeParams(_TestCase): - # TODO(preview): this seems wrong: why is no operation registered in the service definition? - # This is not an error currently @staticmethod def build(): @nexusrpc.service @@ -85,10 +83,7 @@ class H: @sync_operation async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... - error_message = ( - r"does not match an operation method name in the service definition. " - r"Available method names in the service definition: \[none\]." - ) + error_message = "has 0 type parameters" @pytest.mark.parametrize( From 5b255efe4e69c9a2abf6c348e4beb25e19a5ee76 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 15:09:39 -0400 Subject: [PATCH 133/178] Create parallel syncio tree --- src/nexusrpc/_serializer.py | 31 +++------- src/nexusrpc/handler/_core.py | 9 ++- src/nexusrpc/syncio/__init__.py | 1 + src/nexusrpc/syncio/_serializer.py | 59 +++++++++++++++++++ .../{handler/syncio.py => syncio/handler.py} | 11 ++-- tests/handler/test_handler_syncio.py | 5 +- ...test_service_handler_from_user_instance.py | 5 +- ...corator_creates_valid_operation_handler.py | 4 +- 8 files changed, 89 insertions(+), 36 deletions(-) create mode 100644 src/nexusrpc/syncio/__init__.py create mode 100644 src/nexusrpc/syncio/_serializer.py rename src/nexusrpc/{handler/syncio.py => syncio/handler.py} (95%) diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index 5a594bd..e62c16f 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -5,7 +5,6 @@ Any, AsyncIterable, Awaitable, - Iterable, Mapping, Optional, Protocol, @@ -57,7 +56,13 @@ def deserialize( ... -class LazyValue: +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. @@ -68,7 +73,7 @@ def __init__( self, serializer: Serializer, headers: Mapping[str, str], - stream: Optional[Union[AsyncIterable[bytes], Iterable[bytes]]] = None, + stream: Optional[AsyncIterable[bytes]] = None, ) -> None: """ Args: @@ -101,23 +106,3 @@ async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: ), as_type=as_type, ) - - def consume_sync(self, as_type: Optional[Type[Any]] = None) -> Any: - """ - Consume the underlying reader stream, deserializing via the embedded serializer. - """ - # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? - if self.stream is None: - return self.serializer.deserialize( - Content(headers=self.headers, data=None), 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/handler/_core.py b/src/nexusrpc/handler/_core.py index 715a2a2..f303363 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -108,7 +108,8 @@ def my_op(...) from typing_extensions import Self import nexusrpc -from nexusrpc import HandlerError, HandlerErrorType, LazyValue, OperationInfo +from nexusrpc import HandlerError, HandlerErrorType, OperationInfo +from nexusrpc._serializer import LazyValueT from nexusrpc._util import get_service_definition from nexusrpc.handler._util import is_async_callable @@ -137,7 +138,7 @@ class AbstractHandler(ABC): def start_operation( self, ctx: StartOperationContext, - input: LazyValue, + input: LazyValueT, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, @@ -256,12 +257,14 @@ class Handler(BaseServiceCollectionHandler): Operation requests are delegated to a :py:class:`ServiceHandler` based on the service name in the operation context. + + This class uses `async def` methods. For `def` methods, see :py:class:`nexusrpc.syncio.Handler`. """ async def start_operation( self, ctx: StartOperationContext, - input: LazyValue, + input: LazyValueT, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, diff --git a/src/nexusrpc/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py new file mode 100644 index 0000000..fd0909b --- /dev/null +++ b/src/nexusrpc/syncio/__init__.py @@ -0,0 +1 @@ +from ._serializer import LazyValue as LazyValue diff --git a/src/nexusrpc/syncio/_serializer.py b/src/nexusrpc/syncio/_serializer.py new file mode 100644 index 0000000..a6515f8 --- /dev/null +++ b/src/nexusrpc/syncio/_serializer.py @@ -0,0 +1,59 @@ +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. + """ + # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? + if self.stream is None: + return self.serializer.deserialize( + Content(headers=self.headers, data=None), 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/handler/syncio.py b/src/nexusrpc/syncio/handler.py similarity index 95% rename from src/nexusrpc/handler/syncio.py rename to src/nexusrpc/syncio/handler.py index dbe7f83..646270e 100644 --- a/src/nexusrpc/handler/syncio.py +++ b/src/nexusrpc/syncio/handler.py @@ -9,7 +9,8 @@ ) import nexusrpc -from nexusrpc import InputT, LazyValue, OperationInfo, OutputT +from nexusrpc import InputT, OperationInfo, OutputT +from nexusrpc._serializer import LazyValueT from nexusrpc._types import ServiceHandlerT from nexusrpc._util import ( set_operation_definition, @@ -30,7 +31,7 @@ is_async_callable, ) -from ._operation_handler import OperationHandler +from ..handler._operation_handler import OperationHandler class Handler(BaseServiceCollectionHandler): @@ -41,12 +42,14 @@ class Handler(BaseServiceCollectionHandler): Operation requests are delegated to a :py:class:`ServiceHandler` based on the service name in the operation context. + + This class uses `def` methods. For `async def` methods, see :py:class:`nexusrpc.handler.Handler`. """ def start_operation( self, ctx: StartOperationContext, - input: LazyValue, + input: LazyValueT, ) -> Union[ StartOperationResultSync[Any], StartOperationResultAsync, @@ -60,7 +63,7 @@ def start_operation( 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_sync(as_type=op.input_type) + deserialized_input = input.consume(as_type=op.input_type) if is_async_callable(op_handler.start): raise RuntimeError( diff --git a/tests/handler/test_handler_syncio.py b/tests/handler/test_handler_syncio.py index 17ef73b..92af6be 100644 --- a/tests/handler/test_handler_syncio.py +++ b/tests/handler/test_handler_syncio.py @@ -4,13 +4,14 @@ import pytest -from nexusrpc import Content, LazyValue +from nexusrpc import Content from nexusrpc.handler import ( StartOperationContext, StartOperationResultSync, service_handler, ) -from nexusrpc.handler.syncio import Handler, sync_operation +from nexusrpc.syncio import LazyValue +from nexusrpc.syncio.handler import Handler, sync_operation class _TestCase: diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py index 7bd4bb0..ef97fce 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,7 +1,8 @@ from __future__ import annotations -from nexusrpc.handler import StartOperationContext, service_handler, syncio +from nexusrpc.handler import StartOperationContext, service_handler from nexusrpc.handler._core import ServiceHandler +from nexusrpc.syncio import handler as syncio_handler # TODO(preview): test operation_handler version of this @@ -17,7 +18,7 @@ def __call__( ) -> int: return input - sync_operation_with_callable_instance = syncio.sync_operation( + sync_operation_with_callable_instance = syncio_handler.sync_operation( name="sync_operation_with_callable_instance", )( SyncOperationWithCallableInstance(), 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 index e665bfa..cecef8a 100644 --- 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 @@ -8,9 +8,9 @@ StartOperationResultSync, service_handler, sync_operation, - syncio, ) from nexusrpc.handler._util import is_async_callable +from nexusrpc.syncio import handler as syncio_handler @service_handler @@ -18,7 +18,7 @@ class MyServiceHandler: def __init__(self): self.mutable_container = [] - @syncio.sync_operation + @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. From 2675366f85ee6808578ed05b46d7b9d2209c0449 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 16:42:22 -0400 Subject: [PATCH 134/178] HandlerError docstrings and properties --- src/nexusrpc/__init__.py | 80 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 8904752..84e82d5 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -90,14 +90,67 @@ class HandlerErrorType(Enum): """ 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. + """ class HandlerError(Exception): @@ -114,9 +167,6 @@ def __init__( *, type: HandlerErrorType, cause: Optional[BaseException] = None, - # Whether this error should be considered retryable. If not specified, retry - # behavior is determined from the error type. For example, INTERNAL is retryable - # by default unless specified otherwise. retryable: Optional[bool] = None, ): """ @@ -132,5 +182,25 @@ def __init__( """ super().__init__(message) self.__cause__ = cause - self.type = type - self.retryable = retryable + 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 From 2d1ff8150c1e0948fcd4a5b20d664f18be2bb158 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 16:42:32 -0400 Subject: [PATCH 135/178] Delete cause --- src/nexusrpc/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 84e82d5..f80c1b2 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -166,7 +166,6 @@ def __init__( message: str, *, type: HandlerErrorType, - cause: Optional[BaseException] = None, retryable: Optional[bool] = None, ): """ @@ -175,13 +174,10 @@ def __init__( :param message: A descriptive message for the error. This will become the `message` in the resulting Nexus Failure object. :param type: The type of handler error. - :param cause: The original exception that caused this handler error, if any. - This will be encoded in the `details` of the Nexus Failure object. :param retryable: Whether this error should be retried. If not provided, the default behavior for the error type is used. """ super().__init__(message) - self.__cause__ = cause self._type = type self._retryable = retryable From 62ebe966fe7205fe0c863450c1a99bf790d7d331 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 17:11:31 -0400 Subject: [PATCH 136/178] poe commands --- CONTRIBUTING.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14def33..2a764d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,19 +1,15 @@ -### Type-checking, Linting, and Formatting +### Type-checking and linting ```sh -uv run pyright -uv run mypy --check-untyped-defs . -uv run ruff check --select I -uv run ruff format --check +uv run poe lint ``` ### Formatting ``` -uv run ruff check --select I --fix -uv run ruff format +uv run poe format ``` ### API docs ``` -uv run pydoctor src/nexusrpc -``` \ No newline at end of file +uv run poe docs +``` From 8384e5d42506946122de701a90c7c6a696e63703 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 17:19:24 -0400 Subject: [PATCH 137/178] Deploy API docs to GH pages --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 065cf1d..8bd2ed8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,12 +39,34 @@ jobs: name: coverage-html-report-${{ matrix.os }}-${{ matrix.python-version }} path: coverage_html_report/ + deploy-docs: + runs-on: ubuntu-latest + needs: lint-test-docs + if: ${{ github.ref == 'refs/heads/main' }} + 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: Deploy prod API docs - if: ${{ github.ref == 'refs/heads/main' }} - env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - run: npx vercel deploy build/apidocs -t ${{ secrets.VERCEL_TOKEN }} --prod --yes + - 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 From 25af294948acf8d99ddf11d6917d13d49e6a8830 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 17:19:46 -0400 Subject: [PATCH 138/178] Docstrings --- src/nexusrpc/_types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/nexusrpc/_types.py b/src/nexusrpc/_types.py index dd9f300..deaf1cf 100644 --- a/src/nexusrpc/_types.py +++ b/src/nexusrpc/_types.py @@ -2,14 +2,14 @@ from typing import TypeVar -# Operation input type InputT = TypeVar("InputT", contravariant=True) +"""Operation input type""" -# Operation output type OutputT = TypeVar("OutputT", covariant=True) +"""Operation output type""" -# A user's service handler class, typically decorated with @service_handler ServiceHandlerT = TypeVar("ServiceHandlerT") +"""A user's service handler class, typically decorated with @service_handler""" -# A user's service definition class, typically decorated with @service ServiceDefinitionT = TypeVar("ServiceDefinitionT") +"""A user's service definition class, typically decorated with @service""" From e83999bf246ed2ec592016e906abeacfeaf1d6ea Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 17:32:46 -0400 Subject: [PATCH 139/178] TEMP: reduce GHA matrix GH is refusing to run them all --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bd2ed8..96628cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ['3.9', '3.13', '3.14'] - os: [ubuntu-latest, ubuntu-arm, macos-intel, macos-arm, windows-latest] + python-version: ['3.9', '3.13'] + os: [ubuntu-latest, windows-latest] steps: - name: Checkout repository From ec0d892ee8a79338442a07d215d812f3fc28e992 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 17:40:57 -0400 Subject: [PATCH 140/178] Don't skip deploy docs --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96628cb..8cb44ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: deploy-docs: runs-on: ubuntu-latest needs: lint-test-docs - if: ${{ github.ref == 'refs/heads/main' }} + # TODO: deploy on releases only permissions: contents: read pages: write From 903dcfe465eb702aa22b486722451172e84745b6 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 19:15:59 -0400 Subject: [PATCH 141/178] Move utils --- src/nexusrpc/_util.py | 48 ++++++++++++++++- src/nexusrpc/handler/_core.py | 3 +- src/nexusrpc/handler/_decorators.py | 2 +- src/nexusrpc/handler/_operation_handler.py | 2 +- src/nexusrpc/handler/_util.py | 52 ++----------------- src/nexusrpc/syncio/handler.py | 8 ++- ...rrectly_functioning_operation_factories.py | 3 +- ...corator_creates_valid_operation_handler.py | 2 +- tests/test_get_input_and_output_types.py | 6 +-- tests/test_util.py | 2 +- 10 files changed, 63 insertions(+), 65 deletions(-) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index be4be2b..1580371 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -1,6 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Optional, Type +import functools +import inspect +import typing +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Type + +from typing_extensions import TypeGuard import nexusrpc @@ -77,6 +82,47 @@ def set_operation_factory( 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_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) + + +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 + + # See # https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index f303363..b2c21fc 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -110,8 +110,7 @@ def my_op(...) import nexusrpc from nexusrpc import HandlerError, HandlerErrorType, OperationInfo from nexusrpc._serializer import LazyValueT -from nexusrpc._util import get_service_definition -from nexusrpc.handler._util import is_async_callable +from nexusrpc._util import get_service_definition, is_async_callable from ._common import ( CancelOperationContext, diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index c6130c0..05752fa 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -17,6 +17,7 @@ from nexusrpc import InputT, OutputT from nexusrpc._types import ServiceHandlerT from nexusrpc._util import ( + get_callable_name, get_service_definition, set_operation_definition, set_operation_factory, @@ -24,7 +25,6 @@ ) from nexusrpc.handler._common import StartOperationContext from nexusrpc.handler._util import ( - get_callable_name, get_start_method_input_and_output_type_annotations, ) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index a8ab043..5facae9 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -16,7 +16,7 @@ import nexusrpc._service from nexusrpc import InputT, OutputT, get_operation_factory from nexusrpc._types import ServiceHandlerT -from nexusrpc.handler._util import is_async_callable, is_subtype +from nexusrpc._util import is_async_callable, is_subtype from .. import OperationInfo from ._common import ( diff --git a/src/nexusrpc/handler/_util.py b/src/nexusrpc/handler/_util.py index 26eca2d..6474c7e 100644 --- a/src/nexusrpc/handler/_util.py +++ b/src/nexusrpc/handler/_util.py @@ -1,11 +1,9 @@ from __future__ import annotations -import functools -import inspect import typing import warnings from typing import ( - Any, + TYPE_CHECKING, Awaitable, Callable, Optional, @@ -14,10 +12,11 @@ Union, ) -from typing_extensions import TypeGuard +from nexusrpc.handler import StartOperationContext + +if TYPE_CHECKING: + from nexusrpc import InputT, OutputT -from nexusrpc import InputT, OutputT -from nexusrpc.handler._common import StartOperationContext ServiceHandlerT = TypeVar("ServiceHandlerT") @@ -65,44 +64,3 @@ def get_start_method_input_and_output_type_annotations( input_type = None return input_type, output_type - - -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) - - -# 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)) - ) diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index 646270e..21e18b5 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -13,6 +13,8 @@ from nexusrpc._serializer import LazyValueT from nexusrpc._types import ServiceHandlerT from nexusrpc._util import ( + get_callable_name, + is_async_callable, set_operation_definition, set_operation_factory, ) @@ -25,11 +27,7 @@ StartOperationResultSync, ) from nexusrpc.handler._core import BaseServiceCollectionHandler -from nexusrpc.handler._util import ( - get_callable_name, - get_start_method_input_and_output_type_annotations, - is_async_callable, -) +from nexusrpc.handler._util import get_start_method_input_and_output_type_annotations from ..handler._operation_handler import OperationHandler 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 index 563559e..d93d967 100644 --- 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 @@ -9,7 +9,7 @@ import nexusrpc from nexusrpc import InputT, OutputT -from nexusrpc._util import get_service_definition +from nexusrpc._util import get_service_definition, is_async_callable from nexusrpc.handler import ( CancelOperationContext, FetchOperationInfoContext, @@ -23,7 +23,6 @@ ) from nexusrpc.handler._core import collect_operation_handler_factories_by_method_name from nexusrpc.handler._decorators import operation_handler -from nexusrpc.handler._util import is_async_callable @dataclass 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 index cecef8a..5467752 100644 --- 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 @@ -3,13 +3,13 @@ import pytest from nexusrpc import get_operation_factory +from nexusrpc._util import is_async_callable from nexusrpc.handler import ( StartOperationContext, StartOperationResultSync, service_handler, sync_operation, ) -from nexusrpc.handler._util import is_async_callable from nexusrpc.syncio import handler as syncio_handler diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index 5b32602..63186fa 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -11,10 +11,8 @@ import pytest -from nexusrpc.handler import ( - StartOperationContext, - get_start_method_input_and_output_type_annotations, -) +from nexusrpc.handler import StartOperationContext +from nexusrpc.handler._util import get_start_method_input_and_output_type_annotations class Input: diff --git a/tests/test_util.py b/tests/test_util.py index 17f3d36..207b4a6 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,6 @@ from functools import partial -from nexusrpc.handler._util import is_async_callable +from nexusrpc._util import is_async_callable def test_async_def_is_async_callable(): From 8838a2581225aeaae9c435c428d70905408a7bc0 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 19:20:15 -0400 Subject: [PATCH 142/178] Import-time validation of def/async def methods used with sync_operation --- src/nexusrpc/handler/_decorators.py | 6 ++ src/nexusrpc/syncio/handler.py | 4 ++ tests/handler/test_invalid_usage.py | 101 ++++++++++++++++++++++++---- 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 05752fa..a97a513 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -19,6 +19,7 @@ from nexusrpc._util import ( get_callable_name, get_service_definition, + is_async_callable, set_operation_definition, set_operation_factory, set_service_definition, @@ -253,6 +254,11 @@ def sync_operation( """ Decorator marking a method as the start method for a synchronous operation. """ + 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 decorator( start: Callable[ diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index 21e18b5..ca6ee2f 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -190,6 +190,10 @@ def sync_operation( """ Decorator marking a method as the start method for a synchronous operation. """ + 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], diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py index 0b9e972..0a5229b 100644 --- a/tests/handler/test_invalid_usage.py +++ b/tests/handler/test_invalid_usage.py @@ -3,12 +3,22 @@ handler implementations. """ +import concurrent.futures from typing import Any, Callable import pytest import nexusrpc -from nexusrpc.handler import StartOperationContext, service_handler, sync_operation +from nexusrpc.handler import ( + Handler, + StartOperationContext, + service_handler, + sync_operation, +) +from nexusrpc.syncio.handler import ( + Handler as SyncioHandler, + sync_operation as syncio_sync_operation, +) class _TestCase: @@ -20,11 +30,11 @@ class OperationHandlerOverridesNameInconsistentlyWithServiceDefinition(_TestCase @staticmethod def build(): @nexusrpc.service - class S: + class SD: my_op: nexusrpc.Operation[None, None] - @service_handler(service=S) - class H: + @service_handler(service=SD) + class SH: @sync_operation(name="foo") async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... @@ -35,12 +45,12 @@ class ServiceDefinitionHasExtraOp(_TestCase): @staticmethod def build(): @nexusrpc.service - class S: + class SD: my_op_1: nexusrpc.Operation[None, None] my_op_2: nexusrpc.Operation[None, None] - @service_handler(service=S) - class H: + @service_handler(service=SD) + class SH: @sync_operation async def my_op_1( self, ctx: StartOperationContext, input: None @@ -53,11 +63,11 @@ class ServiceHandlerHasExtraOp(_TestCase): @staticmethod def build(): @nexusrpc.service - class S: + class SD: my_op_1: nexusrpc.Operation[None, None] - @service_handler(service=S) - class H: + @service_handler(service=SD) + class SH: @sync_operation async def my_op_1( self, ctx: StartOperationContext, input: None @@ -75,17 +85,78 @@ class ServiceDefinitionOperationHasNoTypeParams(_TestCase): @staticmethod def build(): @nexusrpc.service - class S: + class SD: my_op: nexusrpc.Operation - @service_handler(service=S) - class H: + @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: + # assert-type-error: Argument 1 to "sync_operation" has incompatible type "Callable[[H, StartOperationContext, None], None]"; expected "Callable[[H, StartOperationContext, None], Awaitable[Never]]" + @sync_operation # type: ignore + 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" + + @pytest.mark.parametrize( "test_case", [ @@ -93,6 +164,10 @@ async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... ServiceDefinitionOperationHasNoTypeParams, ServiceDefinitionHasExtraOp, ServiceHandlerHasExtraOp, + AsyncioDecoratorWithSyncioMethod, + SyncioDecoratorWithAsyncioMethod, + AsyncioHandlerWithSyncioOperation, + SyncioHandlerWithAsyncioOperation, ], ) def test_invalid_usage(test_case: _TestCase): From 4bffd378603125adb28d122550a9f711cf3cb3e7 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 21:56:09 -0400 Subject: [PATCH 143/178] Remove TODOs --- src/nexusrpc/_serializer.py | 1 - src/nexusrpc/syncio/_serializer.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index e62c16f..3b543dd 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -91,7 +91,6 @@ async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: """ Consume the underlying reader stream, deserializing via the embedded serializer. """ - # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? if self.stream is None: return await self.serializer.deserialize( Content(headers=self.headers, data=None), as_type=as_type diff --git a/src/nexusrpc/syncio/_serializer.py b/src/nexusrpc/syncio/_serializer.py index a6515f8..67ecf1e 100644 --- a/src/nexusrpc/syncio/_serializer.py +++ b/src/nexusrpc/syncio/_serializer.py @@ -42,7 +42,6 @@ def consume(self, as_type: Optional[Type[Any]] = None) -> Any: """ Consume the underlying reader stream, deserializing via the embedded serializer. """ - # TODO(prerelease): HandlerError(BAD_REQUEST) on error while deserializing? if self.stream is None: return self.serializer.deserialize( Content(headers=self.headers, data=None), as_type=as_type From a364208f5e9bf8baa2c1558d5684490090c0e7d3 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 19:42:21 -0400 Subject: [PATCH 144/178] Differentiate syncio/asyncio handlers --- src/nexusrpc/handler/_core.py | 129 ++++++++++++---------------- src/nexusrpc/handler/_decorators.py | 11 +-- src/nexusrpc/syncio/handler.py | 73 ++++++++++------ 3 files changed, 107 insertions(+), 106 deletions(-) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index b2c21fc..d917569 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -92,7 +92,6 @@ def my_op(...) import asyncio import concurrent.futures -import inspect from abc import ABC, abstractmethod from dataclasses import dataclass from typing import ( @@ -105,7 +104,7 @@ def my_op(...) Union, ) -from typing_extensions import Self +from typing_extensions import Self, TypeGuard import nexusrpc from nexusrpc import HandlerError, HandlerErrorType, OperationInfo @@ -209,7 +208,12 @@ def __init__( 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: Mapping[str, ServiceHandler] = {} + 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( @@ -221,23 +225,12 @@ def __init__( # 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 self.service_handlers: + if sh.service.name in service_handlers: raise RuntimeError( f"Service '{sh.service.name}' has already been registered." ) - if self.executor is None: - for op_name, operation_handler in sh.operation_handlers.items(): - if not is_async_callable(operation_handler.start): - raise RuntimeError( - f"Service '{sh.service.name}' operation '{op_name}' " - "start method must be an `async def` if no executor is provided." - ) - if not is_async_callable(operation_handler.cancel): - raise RuntimeError( - f"Service '{sh.service.name}' operation '{op_name}' " - "cancel method must be an `async def` if no executor is provided." - ) - self.service_handlers[sh.service.name] = sh + 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.""" @@ -254,12 +247,25 @@ class Handler(BaseServiceCollectionHandler): """ A Nexus handler manages a collection of Nexus service handlers. - Operation requests are delegated to a :py:class:`ServiceHandler` based on the service - name in the operation context. + 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. - This class uses `async def` methods. For `def` methods, see :py:class:`nexusrpc.syncio.Handler`. + The methods of this class itself are `async def`. For a handler class with `def` + methods, see :py:class:`nexusrpc.syncio.Handler`. """ + 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, @@ -278,26 +284,14 @@ async def start_operation( 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): - # TODO(preview): apply middleware stack as composed awaitables return await op_handler.start(ctx, deserialized_input) else: - # TODO(preview): apply middleware stack as composed functions - if not self.executor: - raise RuntimeError( - "Operation start handler method is not an `async def` but " - "no executor was provided to the Handler constructor. " - ) - result = await self.executor.submit_to_event_loop( + assert self.executor + return await self.executor.submit_to_event_loop( op_handler.start, ctx, deserialized_input ) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation start handler method {op_handler.start} returned an " - "awaitable but is not an `async def` coroutine function." - ) - return result async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: """Handle a Cancel Operation request. @@ -311,20 +305,8 @@ async def cancel_operation(self, ctx: CancelOperationContext, token: str) -> Non if is_async_callable(op_handler.cancel): return await op_handler.cancel(ctx, token) else: - if not self.executor: - raise RuntimeError( - "Operation cancel handler method is not an `async def` function but " - "no executor was provided to the Handler constructor." - ) - result = await self.executor.submit_to_event_loop( - op_handler.cancel, ctx, token - ) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation cancel handler method {op_handler.cancel} returned an " - "awaitable but is not an `async def` function." - ) - return result + assert self.executor + return self.executor.submit(op_handler.cancel, ctx, token).result() async def fetch_operation_info( self, ctx: FetchOperationInfoContext, token: str @@ -334,20 +316,8 @@ async def fetch_operation_info( if is_async_callable(op_handler.fetch_info): return await op_handler.fetch_info(ctx, token) else: - if not self.executor: - raise RuntimeError( - "Operation fetch_info handler method is not an `async def` function but " - "no executor was provided to the Handler constructor." - ) - result = await self.executor.submit_to_event_loop( - op_handler.fetch_info, ctx, token - ) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation fetch_info handler method {op_handler.fetch_info} returned an " - "awaitable but is not an `async def` function." - ) - return result + assert self.executor + return self.executor.submit(op_handler.fetch_info, ctx, token).result() async def fetch_operation_result( self, ctx: FetchOperationResultContext, token: str @@ -362,20 +332,27 @@ async def fetch_operation_result( if is_async_callable(op_handler.fetch_result): return await op_handler.fetch_result(ctx, token) else: - if not self.executor: - raise RuntimeError( - "Operation fetch_result handler method is not an `async def` function but " - "no executor was provided to the Handler constructor." - ) - result = await self.executor.submit_to_event_loop( - op_handler.fetch_result, ctx, token + 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." ) - if inspect.isawaitable(result): - raise RuntimeError( - f"Operation fetch_result handler method {op_handler.fetch_result} returned an " - "awaitable but is not an `async def` function." - ) - return result + return True @dataclass(frozen=True) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index a97a513..ed818ee 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -254,17 +254,18 @@ def sync_operation( """ Decorator marking a method as the start method for a synchronous operation. """ - 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 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]: diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index ca6ee2f..3c0950c 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -1,13 +1,17 @@ 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._serializer import LazyValueT @@ -36,14 +40,29 @@ 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 delegated to a :py:class:`ServiceHandler` based on the service - name in the operation context. + Operation requests are dispatched to a :py:class:`ServiceHandler` based on the + service name in the operation context. - This class uses `def` methods. For `async def` methods, see :py:class:`nexusrpc.handler.Handler`. """ + 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, @@ -62,18 +81,8 @@ def start_operation( 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) - - if is_async_callable(op_handler.start): - raise RuntimeError( - "Operation start handler method is an `async def` and " - "cannot be called from a sync handler. " - ) - # TODO(preview): apply middleware stack as composed functions - if not self.executor: - raise RuntimeError( - "Operation start handler method is not an `async def` but " - "no executor was provided to the Handler constructor. " - ) + 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: @@ -85,18 +94,13 @@ def cancel_operation(self, ctx: CancelOperationContext, token: str) -> None: """ service_handler = self._get_service_handler(ctx.service) op_handler = service_handler._get_operation_handler(ctx.operation) - if is_async_callable(op_handler.cancel): + assert self._assert_not_async_callable(op_handler.cancel) + if not self.executor: raise RuntimeError( - "Operation cancel handler method is an `async def` and " - "cannot be called from a sync handler. " + "Operation cancel handler method is not an `async def` method but " + "no executor was provided to the Handler constructor." ) - else: - if not self.executor: - raise RuntimeError( - "Operation cancel handler method is not an `async def` function but " - "no executor was provided to the Handler constructor." - ) - return self.executor.submit(op_handler.cancel, ctx, token).result() + return self.executor.submit(op_handler.cancel, ctx, token).result() def fetch_operation_info( self, ctx: FetchOperationInfoContext, token: str @@ -108,6 +112,25 @@ def fetch_operation_result( ) -> 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]): """ From f2e4ede39d98e79efc33ed8aea1a5df5e63fc237 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 29 Jun 2025 23:03:27 -0400 Subject: [PATCH 145/178] Test type checking --- pyproject.toml | 4 +- tests/handler/test_invalid_usage.py | 3 +- tests/test_type_checking.py | 92 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/test_type_checking.py diff --git a/pyproject.toml b/pyproject.toml index 2bc1d7f..7acf48e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,8 @@ packages = ["src/nexusrpc"] [tool.poe.tasks] lint = [ - {cmd = "uv run pyright"}, - {cmd = "uv run mypy --check-untyped-defs ."}, + {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"}, ] diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py index 0a5229b..9ebcd4a 100644 --- a/tests/handler/test_invalid_usage.py +++ b/tests/handler/test_invalid_usage.py @@ -105,8 +105,7 @@ class SD: @service_handler(service=SD) class SH: - # assert-type-error: Argument 1 to "sync_operation" has incompatible type "Callable[[H, StartOperationContext, None], None]"; expected "Callable[[H, StartOperationContext, None], Awaitable[Never]]" - @sync_operation # type: ignore + @sync_operation # assert-type-error: 'Argument 1 to "sync_operation" has incompatible type' def my_op(self, ctx: StartOperationContext, input: None) -> None: ... error_message = ( 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 From 68ee1db99a7b25d01c310eb69edaa97d50889b46 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 07:43:13 -0400 Subject: [PATCH 146/178] Docstrings --- src/nexusrpc/__init__.py | 97 ++++++++++++++++++++++++-------- src/nexusrpc/handler/__init__.py | 10 +++- src/nexusrpc/syncio/__init__.py | 14 +++++ src/nexusrpc/syncio/handler.py | 12 ++++ 4 files changed, 110 insertions(+), 23 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index f80c1b2..8d47ba5 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -1,3 +1,18 @@ +""" +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 dataclasses import dataclass from enum import Enum from typing import Optional @@ -18,19 +33,22 @@ @dataclass(frozen=True) class Link: """ - Link contains a URL and a Type that can be used to decode the URL. - Links can contain any arbitrary information as a percent-encoded URL. - It can be used to pass information about the caller to the handler, or vice versa. + 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. + Link URL. + + Must be percent-encoded. """ type: str """ - Can describe an data type for decoding the URL. + A data type for decoding the URL. Valid chars: alphanumeric, '_', '.', '/' """ @@ -41,10 +59,41 @@ 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" - RUNNING = "running" + """ + 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) @@ -64,23 +113,26 @@ class OperationInfo: """ -class OperationErrorState(Enum): - """ - The state of an operation as described by an OperationError. - """ - - FAILED = "failed" - CANCELED = "canceled" - - 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: """ def __init__(self, message: str, *, state: OperationErrorState): super().__init__(message) - self.state = state + self._state = state + + @property + def state(self) -> OperationErrorState: + """ + The state of the operation. + """ + return self._state class HandlerErrorType(Enum): @@ -169,13 +221,14 @@ def __init__( retryable: Optional[bool] = None, ): """ - Initializes a new HandlerError. + 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 message: A descriptive message for the error. This will become the `message` - in the resulting Nexus Failure object. - :param type: The type of handler error. - :param retryable: Whether this error should be retried. If not - provided, the default behavior for the error type is used. + :param retryable: """ super().__init__(message) self._type = type diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index db086dc..6b8eba6 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -1,7 +1,15 @@ +""" +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. +""" # TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" # TODO(preview): pass mypy - from __future__ import annotations from ._common import ( diff --git a/src/nexusrpc/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py index fd0909b..c6eec22 100644 --- a/src/nexusrpc/syncio/__init__.py +++ b/src/nexusrpc/syncio/__init__.py @@ -1 +1,15 @@ +""" +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 as LazyValue diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index 3c0950c..fad3122 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -1,3 +1,15 @@ +""" +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 __future__ import annotations import concurrent.futures From aa35a0757ca8ffdae3dc1474c6f791b8889890dd Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 07:57:30 -0400 Subject: [PATCH 147/178] Reorder --- src/nexusrpc/__init__.py | 154 ++++++++++++++++++++------------------- 1 file changed, 78 insertions(+), 76 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 8d47ba5..94fc570 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -13,6 +13,8 @@ an asynchronous operation when it is ready. """ +from __future__ import annotations + from dataclasses import dataclass from enum import Enum from typing import Optional @@ -30,29 +32,77 @@ ) -@dataclass(frozen=True) -class Link: +class HandlerError(Exception): """ - A Link contains a URL and a type that can be used to decode the URL. + A Nexus handler error. - The URL may contain arbitrary data (percent-encoded). It can be used to pass - information about the caller to the handler, or vice versa. + 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. """ - url: str - """ - Link URL. + def __init__( + self, + message: str, + *, + type: HandlerErrorType, + retryable: Optional[bool] = None, + ): + """ + Initialize a new HandlerError. - Must be percent-encoded. - """ + :param message: A descriptive message for the error. This will become the + `message` in the resulting Nexus Failure object. - type: str + :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): """ - A data type for decoding the URL. + An error that represents "failed" and "canceled" operation results. - Valid chars: alphanumeric, '_', '.', '/' + :param message: A descriptive message for the error. This will become the + `message` in the resulting Nexus Failure object. + + :param state: """ + 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): """ @@ -113,28 +163,6 @@ class OperationInfo: """ -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: - """ - - 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 HandlerErrorType(Enum): """Nexus handler error types. @@ -205,51 +233,25 @@ class HandlerErrorType(Enum): """ -class HandlerError(Exception): +@dataclass(frozen=True) +class Link: """ - A Nexus handler error. + A Link contains a URL and a type that can be used to decode the URL. - 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. + The URL may contain arbitrary data (percent-encoded). It can be used to pass + information about the caller to the handler, or vice versa. """ - 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. + url: str + """ + Link URL. - 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 + Must be percent-encoded. + """ - @property - def type(self) -> HandlerErrorType: - """ - The type of handler error. + type: str + """ + A data type for decoding the URL. - See :py:class:`HandlerErrorType` and - https://github.com/nexus-rpc/api/blob/main/SPEC.md#predefined-handler-errors. - """ - return self._type + Valid chars: alphanumeric, '_', '.', '/' + """ From 75dd11c48b5d22b671547b7c1b1031962ab084cd Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 08:39:01 -0400 Subject: [PATCH 148/178] Add __all__s --- src/nexusrpc/__init__.py | 19 +++++++++++++++++++ src/nexusrpc/handler/__init__.py | 15 +++++++++++++++ src/nexusrpc/syncio/__init__.py | 4 ++++ src/nexusrpc/syncio/handler.py | 6 ++++++ 4 files changed, 44 insertions(+) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 94fc570..c6a07e1 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -31,6 +31,25 @@ get_service_definition as get_service_definition, ) +__all__ = [ + "Content", + "get_operation_factory", + "get_service_definition", + "HandlerError", + "HandlerErrorType", + "InputT", + "LazyValue", + "Link", + "Operation", + "OperationError", + "OperationErrorState", + "OperationInfo", + "OperationState", + "OutputT", + "service", + "ServiceDefinition", +] + class HandlerError(Exception): """ diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 6b8eba6..09608e4 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -30,3 +30,18 @@ from ._util import ( get_start_method_input_and_output_type_annotations as get_start_method_input_and_output_type_annotations, ) + +__all__ = [ + "CancelOperationContext", + "FetchOperationInfoContext", + "FetchOperationResultContext", + "get_start_method_input_and_output_type_annotations", + "Handler", + "OperationContext", + "OperationHandler", + "service_handler", + "StartOperationContext", + "StartOperationResultAsync", + "StartOperationResultSync", + "sync_operation", +] diff --git a/src/nexusrpc/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py index c6eec22..02069e6 100644 --- a/src/nexusrpc/syncio/__init__.py +++ b/src/nexusrpc/syncio/__init__.py @@ -13,3 +13,7 @@ """ from ._serializer import LazyValue as LazyValue + +__all__ = [ + "LazyValue", +] diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index fad3122..6b3a94b 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -47,6 +47,12 @@ from ..handler._operation_handler import OperationHandler +__all__ = [ + "Handler", + "sync_operation", + "SyncOperationHandler", +] + class Handler(BaseServiceCollectionHandler): """ From 61b62d8c34b56940b61b4e3e299df6aa095617ea Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 08:42:16 -0400 Subject: [PATCH 149/178] Examples --- src/nexusrpc/handler/_decorators.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index ed818ee..dcbca55 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -82,17 +82,20 @@ class name will be used. Example: .. code-block:: python - @nexusrpc.handler.service_handler class MyServiceHandler: + @nexusrpc.handler.service_handler + class MyServiceHandler: ... .. code-block:: python - @nexusrpc.handler.service_handler(service=MyService) class MyServiceHandler: + @nexusrpc.handler.service_handler(service=MyService) + class MyServiceHandler: ... .. code-block:: python - @nexusrpc.handler.service_handler(name="my-service") class MyServiceHandler: + @nexusrpc.handler.service_handler(name="my-service") + class MyServiceHandler: ... """ if service and name: From 4ff98440adefdfa70aaa82390f8ce29e288c6e65 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 09:06:04 -0400 Subject: [PATCH 150/178] Add examples --- src/nexusrpc/__init__.py | 35 +++++++++++++++++++ src/nexusrpc/_serializer.py | 17 ++++++++++ src/nexusrpc/_service.py | 13 +++++--- src/nexusrpc/handler/_core.py | 19 +++++++++++ src/nexusrpc/handler/_decorators.py | 52 ++++++++++++++--------------- src/nexusrpc/syncio/handler.py | 40 +++++++++++++++++++++- 6 files changed, 144 insertions(+), 32 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index c6a07e1..afc411f 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -57,6 +57,24 @@ class HandlerError(Exception): 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__( @@ -109,6 +127,23 @@ class OperationError(Exception): `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): diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index 3b543dd..f266303 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -67,6 +67,23 @@ 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__( diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index 8443fed..c5c7640 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -94,14 +94,17 @@ def service( 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 - .. code-block:: python + @nexusrpc.service + class MyNexusService: + my_op: nexusrpc.Operation[MyInput, MyOutput] + another_op: nexusrpc.Operation[str, dict] - @nexusrpc.service - class MyNexusService: - my_operation: nexusrpc.Operation[MyInput, MyOutput] + @nexusrpc.service(name="custom-service-name") + class AnotherService: + process: nexusrpc.Operation[ProcessInput, ProcessOutput] """ # TODO(preview): error on attempt foo = Operation[int, str](name="bar") diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index d917569..3edec3e 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -255,6 +255,25 @@ class Handler(BaseServiceCollectionHandler): 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__( diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index dcbca55..9e0f185 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -82,21 +82,15 @@ class name will be used. Example: .. code-block:: python - @nexusrpc.handler.service_handler - class MyServiceHandler: - ... - - .. code-block:: python - - @nexusrpc.handler.service_handler(service=MyService) - class MyServiceHandler: - ... + from nexusrpc.handler import service_handler, sync_operation - .. code-block:: python - - @nexusrpc.handler.service_handler(name="my-service") + @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( @@ -169,19 +163,6 @@ def operation_handler( Args: method: The method to decorate. name: Optional name for the operation. If not provided, the method name will be used. - - Examples: - .. code-block:: python - - @nexusrpc.handler.operation_handler - def my_operation(self) -> Operation[MyInput, MyOutput]: - ... - - .. code-block:: python - - @nexusrpc.handler.operation_handler(name="my-operation") - def my_operation(self) -> Operation[MyInput, MyOutput]: - ... """ def decorator( @@ -256,6 +237,25 @@ def sync_operation( ]: """ 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( diff --git a/src/nexusrpc/syncio/handler.py b/src/nexusrpc/syncio/handler.py index 6b3a94b..8342b44 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler.py @@ -50,7 +50,6 @@ __all__ = [ "Handler", "sync_operation", - "SyncOperationHandler", ] @@ -67,6 +66,24 @@ class Handler(BaseServiceCollectionHandler): 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] @@ -150,6 +167,7 @@ def _assert_not_async_callable( return True +# TODO(prerelease): should not be exported class SyncOperationHandler(OperationHandler[InputT, OutputT]): """ An :py:class:`nexusrpc.handler.OperationHandler` that is limited to responding synchronously. @@ -230,6 +248,26 @@ def sync_operation( ]: """ 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( From 812eb5959a23c5f7f14c6b60b4907ab9ae500605 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 10:15:54 -0400 Subject: [PATCH 151/178] Only expose intended syncio.handler components --- src/nexusrpc/syncio/handler/__init__.py | 18 ++++++++++++++++++ .../syncio/{handler.py => handler/_core.py} | 19 +------------------ 2 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 src/nexusrpc/syncio/handler/__init__.py rename src/nexusrpc/syncio/{handler.py => handler/_core.py} (95%) 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.py b/src/nexusrpc/syncio/handler/_core.py similarity index 95% rename from src/nexusrpc/syncio/handler.py rename to src/nexusrpc/syncio/handler/_core.py index 8342b44..8ade31f 100644 --- a/src/nexusrpc/syncio/handler.py +++ b/src/nexusrpc/syncio/handler/_core.py @@ -1,15 +1,3 @@ -""" -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 __future__ import annotations import concurrent.futures @@ -45,12 +33,7 @@ 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 - -__all__ = [ - "Handler", - "sync_operation", -] +from ...handler._operation_handler import OperationHandler class Handler(BaseServiceCollectionHandler): From 829e43ad9fcb5d3365600ffe1fae9b0da200ea48 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 10:40:55 -0400 Subject: [PATCH 152/178] Update CONTRIBUTING.md --- CONTRIBUTING.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a764d9..7680f61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,21 @@ -### Type-checking and linting +We will welcome contributions once the SDK has reached a stable release. + +### Type-check and lint ```sh uv run poe lint ``` -### Formatting +### Format ``` uv run poe format ``` +### Test +``` +uv run pytest +``` + ### API docs ``` uv run poe docs From 95d821e851ef69b7f00164194d0bfa66db7adedb Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 13:27:30 -0400 Subject: [PATCH 153/178] Revert "TEMP: reduce GHA matrix" This reverts commit e83999bf246ed2ec592016e906abeacfeaf1d6ea. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cb44ec..1bdf0a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ['3.9', '3.13'] - os: [ubuntu-latest, windows-latest] + python-version: ['3.9', '3.13', '3.14'] + os: [ubuntu-latest, ubuntu-arm, macos-intel, macos-arm, windows-latest] steps: - name: Checkout repository From 375c1f4088dbbb009e8afb3c29e124f48daf83f4 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 13:56:27 -0400 Subject: [PATCH 154/178] ServiceDefinitionT -> ServiceT --- src/nexusrpc/_service.py | 20 +++++++++----------- src/nexusrpc/_types.py | 2 +- src/nexusrpc/_util.py | 4 ++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index c5c7640..5475adf 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -20,7 +20,7 @@ overload, ) -from nexusrpc._types import InputT, OutputT, ServiceDefinitionT +from nexusrpc._types import InputT, OutputT, ServiceT from nexusrpc._util import ( get_annotations, get_service_definition, @@ -70,22 +70,22 @@ def _validation_errors(self) -> list[str]: @overload -def service(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: ... +def service(cls: Type[ServiceT]) -> Type[ServiceT]: ... @overload def service( *, name: Optional[str] = None -) -> Callable[[Type[ServiceDefinitionT]], Type[ServiceDefinitionT]]: ... +) -> Callable[[Type[ServiceT]], Type[ServiceT]]: ... def service( - cls: Optional[Type[ServiceDefinitionT]] = None, + cls: Optional[Type[ServiceT]] = None, *, name: Optional[str] = None, ) -> Union[ - Type[ServiceDefinitionT], - Callable[[Type[ServiceDefinitionT]], Type[ServiceDefinitionT]], + Type[ServiceT], + Callable[[Type[ServiceT]], Type[ServiceT]], ]: """ Decorator marking a class as a Nexus service definition. @@ -115,7 +115,7 @@ class AnotherService: # This will require forming a union of operations disovered via __annotations__ # and __dict__ - def decorator(cls: Type[ServiceDefinitionT]) -> Type[ServiceDefinitionT]: + 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__) @@ -146,9 +146,7 @@ class ServiceDefinition: operations: Mapping[str, Operation[Any, Any]] @staticmethod - def from_class( - user_class: Type[ServiceDefinitionT], name: str - ) -> ServiceDefinition: + 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 @@ -209,7 +207,7 @@ def _validation_errors(self) -> list[str]: @staticmethod def _collect_operations( - user_class: Type[ServiceDefinitionT], + user_class: Type[ServiceT], ) -> dict[str, Operation[Any, Any]]: """Collect operations from a user service definition class. diff --git a/src/nexusrpc/_types.py b/src/nexusrpc/_types.py index deaf1cf..3c9a5fb 100644 --- a/src/nexusrpc/_types.py +++ b/src/nexusrpc/_types.py @@ -11,5 +11,5 @@ ServiceHandlerT = TypeVar("ServiceHandlerT") """A user's service handler class, typically decorated with @service_handler""" -ServiceDefinitionT = TypeVar("ServiceDefinitionT") +ServiceT = TypeVar("ServiceT") """A user's service definition class, typically decorated with @service""" diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index 1580371..a417d1d 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: import nexusrpc from nexusrpc import InputT, OutputT - from nexusrpc._types import ServiceDefinitionT + from nexusrpc._types import ServiceT from nexusrpc.handler._operation_handler import OperationHandler @@ -34,7 +34,7 @@ def get_service_definition( def set_service_definition( - cls: Type[ServiceDefinitionT], service_definition: nexusrpc.ServiceDefinition + cls: Type[ServiceT], service_definition: nexusrpc.ServiceDefinition ) -> None: """Set the :py:class:`nexusrpc.ServiceDefinition` for this object.""" if not isinstance(cls, type): From 717bdbe9752e3a7309f120606eb4e0ac1b35a00c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 14:08:48 -0400 Subject: [PATCH 155/178] Docstrings --- src/nexusrpc/_util.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index a417d1d..aa06a7d 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -45,7 +45,10 @@ def set_service_definition( def get_operation_definition( obj: Any, ) -> Optional[nexusrpc.Operation]: - """Return the :py:class:`nexusrpc.Operation` for the object, or None""" + """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) @@ -53,7 +56,10 @@ def set_operation_definition( obj: Any, operation_definition: nexusrpc.Operation, ) -> None: - """Set the :py:class:`nexusrpc.Operation` for this object.""" + """Set the :py:class:`nexusrpc.Operation` for this object. + + ``obj`` should be an operation start method. + """ setattr(obj, "__nexus_operation__", operation_definition) @@ -63,6 +69,10 @@ def get_operation_factory( 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 @@ -78,7 +88,10 @@ def set_operation_factory( obj: Any, operation_factory: Callable[[Any], OperationHandler[InputT, OutputT]], ) -> None: - """Set the :py:class:`OperationHandler` factory for this object.""" + """Set the :py:class:`OperationHandler` factory for this object. + + ``obj`` should be an operation start method. + """ setattr(obj, "__nexus_operation_factory__", operation_factory) From c439b8865d1dd4f735f325935ee2019dc93df682 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 15:18:25 -0400 Subject: [PATCH 156/178] Do not expose get_operation_factory --- src/nexusrpc/__init__.py | 6 ++++-- src/nexusrpc/handler/_operation_handler.py | 4 ++-- ...ion_handler_decorator_creates_valid_operation_handler.py | 3 +-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index afc411f..4e90d88 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -27,13 +27,14 @@ ) from ._types import InputT as InputT, OutputT as OutputT from ._util import ( - get_operation_factory as get_operation_factory, + get_operation_definition as get_operation_definition, get_service_definition as get_service_definition, + set_operation_definition as set_operation_definition, ) __all__ = [ "Content", - "get_operation_factory", + "get_operation_definition", "get_service_definition", "HandlerError", "HandlerErrorType", @@ -48,6 +49,7 @@ "OutputT", "service", "ServiceDefinition", + "set_operation_definition", ] diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 5facae9..d028a70 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -14,9 +14,9 @@ import nexusrpc import nexusrpc._service -from nexusrpc import InputT, OutputT, get_operation_factory +from nexusrpc import InputT, OutputT from nexusrpc._types import ServiceHandlerT -from nexusrpc._util import is_async_callable, is_subtype +from nexusrpc._util import get_operation_factory, is_async_callable, is_subtype from .. import OperationInfo from ._common import ( 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 index 5467752..535dd57 100644 --- 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 @@ -2,8 +2,7 @@ import pytest -from nexusrpc import get_operation_factory -from nexusrpc._util import is_async_callable +from nexusrpc._util import get_operation_factory, is_async_callable from nexusrpc.handler import ( StartOperationContext, StartOperationResultSync, From c9629c4facbcc8d97501bbaaa17a7f2158b744d8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 15:31:35 -0400 Subject: [PATCH 157/178] Stope doing `as` imports --- src/nexusrpc/__init__.py | 12 ++++-------- src/nexusrpc/handler/__init__.py | 23 +++++++++-------------- src/nexusrpc/syncio/__init__.py | 2 +- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 4e90d88..12e9720 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -20,16 +20,12 @@ from typing import Optional from ._serializer import Content as Content, LazyValue as LazyValue -from ._service import ( - Operation as Operation, - ServiceDefinition as ServiceDefinition, - service as service, -) +from ._service import Operation, ServiceDefinition, service from ._types import InputT as InputT, OutputT as OutputT from ._util import ( - get_operation_definition as get_operation_definition, - get_service_definition as get_service_definition, - set_operation_definition as set_operation_definition, + get_operation_definition, + get_service_definition, + set_operation_definition, ) __all__ = [ diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index 09608e4..d6d3cce 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -13,23 +13,18 @@ from __future__ import annotations from ._common import ( - CancelOperationContext as CancelOperationContext, - FetchOperationInfoContext as FetchOperationInfoContext, - FetchOperationResultContext as FetchOperationResultContext, - OperationContext as OperationContext, - StartOperationContext as StartOperationContext, - StartOperationResultAsync as StartOperationResultAsync, - StartOperationResultSync as StartOperationResultSync, + CancelOperationContext, + FetchOperationInfoContext, + FetchOperationResultContext, + OperationContext, + StartOperationContext, + StartOperationResultAsync, + StartOperationResultSync, ) from ._core import Handler as Handler -from ._decorators import ( - service_handler as service_handler, - sync_operation as sync_operation, -) +from ._decorators import service_handler, sync_operation from ._operation_handler import OperationHandler as OperationHandler -from ._util import ( - get_start_method_input_and_output_type_annotations as get_start_method_input_and_output_type_annotations, -) +from ._util import get_start_method_input_and_output_type_annotations __all__ = [ "CancelOperationContext", diff --git a/src/nexusrpc/syncio/__init__.py b/src/nexusrpc/syncio/__init__.py index 02069e6..7de40be 100644 --- a/src/nexusrpc/syncio/__init__.py +++ b/src/nexusrpc/syncio/__init__.py @@ -12,7 +12,7 @@ version of the `sync_operation` decorator. """ -from ._serializer import LazyValue as LazyValue +from ._serializer import LazyValue __all__ = [ "LazyValue", From 84388d1c2ff9eef9ff3eaccc217fb80f2bf779b8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 15:41:01 -0400 Subject: [PATCH 158/178] Docstring --- src/nexusrpc/handler/_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 078fe04..595c03d 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -50,7 +50,7 @@ class StartOperationContext(OperationContext): request_id: str """ Request ID that may be used by the handler to dedupe a start request. - By default a v4 UUID will be generated by the client. + By default a v4 UUID should be generated by the client. """ callback_url: Optional[str] = None From a48958cb64a3da2492217587f9dd5ffbb9c7859b Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 15:52:00 -0400 Subject: [PATCH 159/178] Make Content.data non-nullable --- src/nexusrpc/_serializer.py | 4 ++-- src/nexusrpc/syncio/_serializer.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index f266303..69b2f9d 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -28,7 +28,7 @@ class Content: User provided keys are treated case-insensitively. """ - data: Optional[bytes] + data: bytes """Request or response data.""" @@ -110,7 +110,7 @@ async def consume(self, as_type: Optional[Type[Any]] = None) -> Any: """ if self.stream is None: return await self.serializer.deserialize( - Content(headers=self.headers, data=None), as_type=as_type + 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") diff --git a/src/nexusrpc/syncio/_serializer.py b/src/nexusrpc/syncio/_serializer.py index 67ecf1e..3cb9b18 100644 --- a/src/nexusrpc/syncio/_serializer.py +++ b/src/nexusrpc/syncio/_serializer.py @@ -44,7 +44,7 @@ def consume(self, as_type: Optional[Type[Any]] = None) -> Any: """ if self.stream is None: return self.serializer.deserialize( - Content(headers=self.headers, data=None), as_type=as_type + 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") From ba528f06d855e1810d6c962527022b602759a394 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 15:56:20 -0400 Subject: [PATCH 160/178] Move content out of __init__.py --- src/nexusrpc/__init__.py | 278 +-------------------- src/nexusrpc/_common.py | 277 ++++++++++++++++++++ src/nexusrpc/_service.py | 2 +- src/nexusrpc/_types.py | 15 -- src/nexusrpc/_util.py | 2 +- src/nexusrpc/handler/_decorators.py | 2 +- src/nexusrpc/handler/_operation_handler.py | 2 +- src/nexusrpc/syncio/handler/_core.py | 2 +- 8 files changed, 294 insertions(+), 286 deletions(-) create mode 100644 src/nexusrpc/_common.py delete mode 100644 src/nexusrpc/_types.py diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 12e9720..1c261f3 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -15,13 +15,19 @@ from __future__ import annotations -from dataclasses import dataclass -from enum import Enum -from typing import Optional - -from ._serializer import Content as Content, LazyValue as LazyValue +from ._common import ( + HandlerError, + HandlerErrorType, + InputT, + Link, + OperationError, + OperationErrorState, + OperationInfo, + OperationState, + OutputT, +) +from ._serializer import Content, LazyValue from ._service import Operation, ServiceDefinition, service -from ._types import InputT as InputT, OutputT as OutputT from ._util import ( get_operation_definition, get_service_definition, @@ -47,263 +53,3 @@ "ServiceDefinition", "set_operation_definition", ] - - -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/_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/_service.py b/src/nexusrpc/_service.py index 5475adf..1de9a5e 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -20,7 +20,7 @@ overload, ) -from nexusrpc._types import InputT, OutputT, ServiceT +from nexusrpc._common import InputT, OutputT, ServiceT from nexusrpc._util import ( get_annotations, get_service_definition, diff --git a/src/nexusrpc/_types.py b/src/nexusrpc/_types.py deleted file mode 100644 index 3c9a5fb..0000000 --- a/src/nexusrpc/_types.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from typing import 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""" diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index aa06a7d..5c49946 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: import nexusrpc from nexusrpc import InputT, OutputT - from nexusrpc._types import ServiceT + from nexusrpc._common import ServiceT from nexusrpc.handler._operation_handler import OperationHandler diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 9e0f185..b979680 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -15,7 +15,7 @@ import nexusrpc from nexusrpc import InputT, OutputT -from nexusrpc._types import ServiceHandlerT +from nexusrpc._common import ServiceHandlerT from nexusrpc._util import ( get_callable_name, get_service_definition, diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index d028a70..59696cc 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -15,7 +15,7 @@ import nexusrpc import nexusrpc._service from nexusrpc import InputT, OutputT -from nexusrpc._types import ServiceHandlerT +from nexusrpc._common import ServiceHandlerT from nexusrpc._util import get_operation_factory, is_async_callable, is_subtype from .. import OperationInfo diff --git a/src/nexusrpc/syncio/handler/_core.py b/src/nexusrpc/syncio/handler/_core.py index 8ade31f..3c1dc8c 100644 --- a/src/nexusrpc/syncio/handler/_core.py +++ b/src/nexusrpc/syncio/handler/_core.py @@ -15,7 +15,7 @@ import nexusrpc from nexusrpc import InputT, OperationInfo, OutputT from nexusrpc._serializer import LazyValueT -from nexusrpc._types import ServiceHandlerT +from nexusrpc._common import ServiceHandlerT from nexusrpc._util import ( get_callable_name, is_async_callable, From 963d9c398ea1373a6a07f324a5b6422b7416f61d Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 15:56:51 -0400 Subject: [PATCH 161/178] Import handler in nexusrpc --- src/nexusrpc/__init__.py | 2 ++ src/nexusrpc/handler/_common.py | 2 +- src/nexusrpc/handler/_core.py | 8 ++++---- src/nexusrpc/handler/_decorators.py | 9 ++++----- src/nexusrpc/handler/_operation_handler.py | 23 ++++++++++------------ 5 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/nexusrpc/__init__.py b/src/nexusrpc/__init__.py index 1c261f3..e8aef51 100644 --- a/src/nexusrpc/__init__.py +++ b/src/nexusrpc/__init__.py @@ -15,6 +15,7 @@ from __future__ import annotations +from . import handler from ._common import ( HandlerError, HandlerErrorType, @@ -38,6 +39,7 @@ "Content", "get_operation_definition", "get_service_definition", + "handler", "HandlerError", "HandlerErrorType", "InputT", diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 595c03d..30d8212 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -9,7 +9,7 @@ Sequence, ) -from nexusrpc import Link, OutputT +from nexusrpc._common import Link, OutputT @dataclass(frozen=True) diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 3edec3e..23984c7 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -106,9 +106,9 @@ def my_op(...) from typing_extensions import Self, TypeGuard -import nexusrpc -from nexusrpc import HandlerError, HandlerErrorType, OperationInfo +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 ( @@ -391,7 +391,7 @@ class contains the :py:class:`OperationHandler` instances themselves. constructor, for example when programmatically creating Nexus service implementations. """ - service: nexusrpc.ServiceDefinition + service: ServiceDefinition operation_handlers: dict[str, OperationHandler[Any, Any]] @classmethod @@ -399,7 +399,7 @@ 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, nexusrpc.ServiceDefinition): + 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 " diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index b979680..8de8505 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -13,9 +13,8 @@ overload, ) -import nexusrpc -from nexusrpc import InputT, OutputT -from nexusrpc._common import ServiceHandlerT +from nexusrpc._common import InputT, OutputT, ServiceHandlerT +from nexusrpc._service import Operation from nexusrpc._util import ( get_callable_name, get_service_definition, @@ -185,7 +184,7 @@ def decorator( set_operation_definition( method, - nexusrpc.Operation( + Operation( name=name or method.__name__, method_name=method.__name__, input_type=input_type, @@ -285,7 +284,7 @@ async def _start(ctx: StartOperationContext, input: InputT) -> OutputT: method_name = get_callable_name(start) set_operation_definition( operation_handler_factory, - nexusrpc.Operation( + Operation( name=name or method_name, method_name=method_name, input_type=input_type, diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 59696cc..0ea1617 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -12,13 +12,10 @@ Union, ) -import nexusrpc -import nexusrpc._service -from nexusrpc import InputT, OutputT -from nexusrpc._common import ServiceHandlerT +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_subtype -from .. import OperationInfo from ._common import ( CancelOperationContext, FetchOperationInfoContext, @@ -154,7 +151,7 @@ async def cancel(self, ctx: CancelOperationContext, token: str) -> None: def collect_operation_handler_factories_by_method_name( user_service_cls: Type[ServiceHandlerT], - service: Optional[nexusrpc.ServiceDefinition], + service: Optional[ServiceDefinition], ) -> dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]]: """ Collect operation handler methods from a user service handler class. @@ -172,7 +169,7 @@ def collect_operation_handler_factories_by_method_name( seen = set() for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): factory, op_defn = get_operation_factory(method) # type: ignore[var-annotated] - if factory and isinstance(op_defn, nexusrpc.Operation): + 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( @@ -204,7 +201,7 @@ def validate_operation_handler_methods( user_methods_by_method_name: dict[ str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]] ], - service_definition: nexusrpc.ServiceDefinition, + service_definition: ServiceDefinition, ) -> None: """Validate operation handler methods against a service definition. @@ -232,7 +229,7 @@ def validate_operation_handler_methods( ) # TODO(prerelease): it should be guaranteed that `method` is a factory, so this next call should be unnecessary. method, method_op_defn = get_operation_factory(method) - if not isinstance(method_op_defn, nexusrpc.Operation): + 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. " @@ -289,7 +286,7 @@ def validate_operation_handler_methods( def service_definition_from_operation_handler_methods( service_name: str, user_methods: dict[str, Callable[[ServiceHandlerT], OperationHandler[Any, Any]]], -) -> nexusrpc.ServiceDefinition: +) -> ServiceDefinition: """ Create a service definition from operation handler factory methods. @@ -298,10 +295,10 @@ def service_definition_from_operation_handler_methods( :py:func:`@nexusrpc.handler.service_handler` decorator. This function is used when that is not the case. """ - op_defns: dict[str, nexusrpc.Operation[Any, Any]] = {} + 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, nexusrpc.Operation): + 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 " @@ -310,4 +307,4 @@ def service_definition_from_operation_handler_methods( ) op_defns[op_defn.name] = op_defn - return nexusrpc.ServiceDefinition(name=service_name, operations=op_defns) + return ServiceDefinition(name=service_name, operations=op_defns) From 065faee192ae6761f730d68ae39a82c08ba5032a Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 16:09:31 -0400 Subject: [PATCH 162/178] Delete spurious check --- src/nexusrpc/handler/_operation_handler.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 0ea1617..f92bfb8 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -276,12 +276,6 @@ def validate_operation_handler_methods( f"subclass of the operation definition output type." ) - if user_methods_by_method_name.keys() > service_definition.operations.keys(): - raise TypeError( - f"Service '{user_service_cls}' implements more operations than the interface '{service_definition}'. " - f"Extra operations: {user_methods_by_method_name.keys() - service_definition.operations.keys()}" - ) - def service_definition_from_operation_handler_methods( service_name: str, From 5430973a774defff8a0eb5075e39377dbda679dc Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 16:27:05 -0400 Subject: [PATCH 163/178] Failing test of enforcement of method name uniqueness --- tests/handler/test_invalid_usage.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py index 9ebcd4a..bab5ad3 100644 --- a/tests/handler/test_invalid_usage.py +++ b/tests/handler/test_invalid_usage.py @@ -156,6 +156,27 @@ async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... 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" + + @pytest.mark.parametrize( "test_case", [ @@ -167,6 +188,7 @@ async def my_op(self, ctx: StartOperationContext, input: None) -> None: ... SyncioDecoratorWithAsyncioMethod, AsyncioHandlerWithSyncioOperation, SyncioHandlerWithAsyncioOperation, + ServiceDefinitionHasDuplicateMethodNames, ], ) def test_invalid_usage(test_case: _TestCase): From 3ea58ff428501a56d006f7dc7e833b1c6dbd4587 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 16:25:19 -0400 Subject: [PATCH 164/178] Enforce method name uniqueness and 1:1 between op_handler and op_defn --- src/nexusrpc/_service.py | 17 +++++++++++------ src/nexusrpc/handler/_operation_handler.py | 8 +++++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/nexusrpc/_service.py b/src/nexusrpc/_service.py index 1de9a5e..a407330 100644 --- a/src/nexusrpc/_service.py +++ b/src/nexusrpc/_service.py @@ -145,6 +145,12 @@ 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. @@ -190,18 +196,17 @@ def from_class(user_class: Type[ServiceT], name: str) -> ServiceDefinition: ) operations[op.name] = op - defn = ServiceDefinition(name=name, operations=operations) - if errors := defn._validation_errors(): - raise ValueError( - f"Service definition {name} has validation errors: {', '.join(errors)}" - ) - return defn + 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 diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index f92bfb8..e8b7d51 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -214,13 +214,14 @@ def validate_operation_handler_methods( 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.get(op_defn.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 " @@ -275,6 +276,11 @@ def validate_operation_handler_methods( 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( From 5fa431adcb40d4404f2058856cf342fe18ff9636 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 16:40:45 -0400 Subject: [PATCH 165/178] Remove accidentally exposed utility function --- src/nexusrpc/handler/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/nexusrpc/handler/__init__.py b/src/nexusrpc/handler/__init__.py index d6d3cce..af54049 100644 --- a/src/nexusrpc/handler/__init__.py +++ b/src/nexusrpc/handler/__init__.py @@ -7,8 +7,6 @@ Nexus service/operation authors will use this module to implement operation handler methods within service handler classes. """ -# TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" -# TODO(preview): pass mypy from __future__ import annotations @@ -24,13 +22,11 @@ from ._core import Handler as Handler from ._decorators import service_handler, sync_operation from ._operation_handler import OperationHandler as OperationHandler -from ._util import get_start_method_input_and_output_type_annotations __all__ = [ "CancelOperationContext", "FetchOperationInfoContext", "FetchOperationResultContext", - "get_start_method_input_and_output_type_annotations", "Handler", "OperationContext", "OperationHandler", From 56b0c6a854e00eaa5cd6d8c0ef06c80877427203 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 16:52:07 -0400 Subject: [PATCH 166/178] Cleanup --- .github/workflows/ci.yml | 2 +- src/nexusrpc/_serializer.py | 2 -- src/nexusrpc/handler/_common.py | 2 -- src/nexusrpc/handler/_core.py | 6 ------ src/nexusrpc/handler/_operation_handler.py | 10 ---------- src/nexusrpc/syncio/handler/_core.py | 3 +-- .../test_service_handler_decorator_requirements.py | 3 --- .../handler/test_service_handler_from_user_instance.py | 2 -- .../test_service_decorator_validation.py | 3 --- .../test_service_definition_inheritance.py | 1 - 10 files changed, 2 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bdf0a2..fd6fcb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: deploy-docs: runs-on: ubuntu-latest needs: lint-test-docs - # TODO: deploy on releases only + # TODO(preview): deploy on releases only permissions: contents: read pages: write diff --git a/src/nexusrpc/_serializer.py b/src/nexusrpc/_serializer.py index 69b2f9d..5f4fa84 100644 --- a/src/nexusrpc/_serializer.py +++ b/src/nexusrpc/_serializer.py @@ -41,8 +41,6 @@ def serialize(self, value: Any) -> Union[Content, Awaitable[Content]]: """Serialize encodes a value into a Content.""" ... - # TODO(prerelease): does None work as the sentinel type here, meaning do not attempt - # type conversion, despite the fact that Python treats None as a valid type? def deserialize( self, content: Content, as_type: Optional[Type[Any]] = None ) -> Union[Any, Awaitable[Any]]: diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 30d8212..134ec39 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -108,7 +108,6 @@ class FetchOperationResultContext(OperationContext): """ -# TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? @dataclass(frozen=True) class StartOperationResultSync(Generic[OutputT]): """ @@ -121,7 +120,6 @@ class StartOperationResultSync(Generic[OutputT]): """ -# TODO(prelease) Make StartOperationResult an ABC with sync and async helpers? @dataclass(frozen=True) class StartOperationResultAsync: """ diff --git a/src/nexusrpc/handler/_core.py b/src/nexusrpc/handler/_core.py index 23984c7..79f4d70 100644 --- a/src/nexusrpc/handler/_core.py +++ b/src/nexusrpc/handler/_core.py @@ -124,12 +124,6 @@ def my_op(...) collect_operation_handler_factories_by_method_name, ) -# TODO(preview): show what it looks like to manually build a service implementation at runtime -# where the operations may be based on some runtime information. - -# TODO(preview): pass pyright strict mode "python.analysis.typeCheckingMode": "strict" -# TODO(preview): pass mypy - class AbstractHandler(ABC): @abstractmethod diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index e8b7d51..19c529c 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -39,15 +39,6 @@ class OperationHandler(ABC, Generic[InputT, OutputT]): instance of :py:class:`OperationHandler` from the start method. """ - # TODO(preview): We are using `def` signatures with union return types in this abstract - # base class to represent both `def` and `async` def implementations in child classes. - # However, this causes VSCode to autocomplete the methods with non-sensical signatures - # such as - # - # async def fetch_result(self, ctx: FetchOperationResultContext, token: str) -> Output | asyncio.Awaitable[Output] - # - # Can we improve this DX? - @abstractmethod def start( self, ctx: StartOperationContext, input: InputT @@ -228,7 +219,6 @@ def validate_operation_handler_methods( f"method name '{op_defn.method_name}'. But this operation is in service " f"definition '{service_definition}'." ) - # TODO(prerelease): it should be guaranteed that `method` is a factory, so this next call should be unnecessary. method, method_op_defn = get_operation_factory(method) if not isinstance(method_op_defn, Operation): raise ValueError( diff --git a/src/nexusrpc/syncio/handler/_core.py b/src/nexusrpc/syncio/handler/_core.py index 3c1dc8c..f378d20 100644 --- a/src/nexusrpc/syncio/handler/_core.py +++ b/src/nexusrpc/syncio/handler/_core.py @@ -14,8 +14,8 @@ import nexusrpc from nexusrpc import InputT, OperationInfo, OutputT -from nexusrpc._serializer import LazyValueT from nexusrpc._common import ServiceHandlerT +from nexusrpc._serializer import LazyValueT from nexusrpc._util import ( get_callable_name, is_async_callable, @@ -150,7 +150,6 @@ def _assert_not_async_callable( return True -# TODO(prerelease): should not be exported class SyncOperationHandler(OperationHandler[InputT, OutputT]): """ An :py:class:`nexusrpc.handler.OperationHandler` that is limited to responding synchronously. diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 15da9af..73a8ee2 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -13,9 +13,6 @@ from nexusrpc.handler._core import ServiceHandler from nexusrpc.handler._decorators import operation_handler -# TODO(prerelease): check return type of op methods including fetch_result and fetch_info -# temporalio.common._type_hints_from_func(hello_nexus.hello2().fetch_result), - class _DecoratorValidationTestCase: UserService: Type[Any] diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py index ef97fce..67054b0 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -4,8 +4,6 @@ from nexusrpc.handler._core import ServiceHandler from nexusrpc.syncio import handler as syncio_handler -# TODO(preview): test operation_handler version of this - @service_handler class MyServiceHandlerWithCallableInstance: diff --git a/tests/service_definition/test_service_decorator_validation.py b/tests/service_definition/test_service_decorator_validation.py index 7ca259b..0a6a57a 100644 --- a/tests/service_definition/test_service_decorator_validation.py +++ b/tests/service_definition/test_service_decorator_validation.py @@ -4,9 +4,6 @@ import nexusrpc -# TODO(preview): test error message when incorrectly applying service decorator to handler class -# TODO(preview): test error message when incorrectly applying service_handler decorator to definition class - class Output: pass diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index a90a00f..c729d71 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -100,7 +100,6 @@ class A1: expected_error = "Did you accidentally use '=' instead of ':'" -# TODO(preview): test mro is honored: that synonymous operation definition in child class wins @pytest.mark.parametrize( "test_case", [ From 72daeab46ccadae720f8d26a7276fe750048a77f Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 17:01:34 -0400 Subject: [PATCH 167/178] Add is_callable utility --- src/nexusrpc/_util.py | 21 +++++-- src/nexusrpc/handler/_operation_handler.py | 3 + ...collects_expected_operation_definitions.py | 60 +++++++++---------- tests/test_util.py | 45 ++++++++++++-- 4 files changed, 87 insertions(+), 42 deletions(-) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index 5c49946..ed963f2 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -116,12 +116,13 @@ def is_async_callable(obj: Any) -> TypeGuard[Callable[..., Awaitable[Any]]]: ) -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) +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: @@ -136,6 +137,14 @@ def get_callable_name(fn: Callable[..., Any]) -> str: 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 diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 19c529c..cdbd79f 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -159,6 +159,9 @@ def collect_operation_handler_factories_by_method_name( ) seen = set() for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): + import pdb + + pdb.set_trace() 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 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 index 5b4c425..9fc1d5f 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -14,6 +14,7 @@ OperationHandler, StartOperationContext, service_handler, + sync_operation, ) from nexusrpc.handler._decorators import operation_handler @@ -146,40 +147,35 @@ def operation(self) -> OperationHandler[Input, Output]: ... } -if False: +class SyncOperationWithCallableInstance(_TestCase): + skip = "TODO(prerelease): update this test after decorator change" - class SyncOperationWithCallableInstance(_TestCase): - skip = "TODO(prerelease): update this test after decorator change" - - @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: - def __call__( - self, - _handler: Any, - ctx: StartOperationContext, - input: Input, - ) -> Output: ... + @nexusrpc.service + class Contract: + sync_operation_with_callable_instance: nexusrpc.Operation[Input, Output] - # TODO(preview): improve the DX here. The decorator cannot be placed on the - # callable class itself, because the user must be responsible for instantiating - # the class to obtain the callable instance. - sync_operation_with_callable_instance = operation_handler( - _sync_operation_with_callable_instance() # type: ignore - ) + @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, - ), - } + 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( @@ -194,7 +190,7 @@ def __call__( # TODO(prerelease): make callable instances work. Input type is not inferred due to # signature differing from normal mathod. See also # SyncHandlerHappyPathWithNonAsyncCallableInstance in temporal tests. - # SyncOperationWithCallableInstance, + SyncOperationWithCallableInstance, ], ) @pytest.mark.asyncio diff --git a/tests/test_util.py b/tests/test_util.py index 207b4a6..4e62382 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,17 +1,45 @@ from functools import partial -from nexusrpc._util import is_async_callable +from nexusrpc._util import is_async_callable, is_callable -def test_async_def_is_async_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(partial(f, a=1)) + assert is_async_callable(g) -def test_async_callable_instance_is_async_callable(): +def test_async_callable_instance(): class f_cls: async def __call__(self, a: int, b: int) -> None: pass @@ -19,5 +47,14 @@ async def __call__(self, a: int, b: int) -> None: 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) From 6f6e01a8094e2515d8f3a1438d4fe5fa4437b22c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 17:03:00 -0400 Subject: [PATCH 168/178] Support callable instances as operation methods --- src/nexusrpc/handler/_operation_handler.py | 12 +++++++----- ...orator_collects_expected_operation_definitions.py | 11 ++++------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index cdbd79f..8f992db 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -14,7 +14,12 @@ 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_subtype +from nexusrpc._util import ( + get_operation_factory, + is_async_callable, + is_callable, + is_subtype, +) from ._common import ( CancelOperationContext, @@ -158,10 +163,7 @@ def collect_operation_handler_factories_by_method_name( else set() ) seen = set() - for _, method in inspect.getmembers(user_service_cls, inspect.isfunction): - import pdb - - pdb.set_trace() + 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 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 index 9fc1d5f..332e948 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -148,7 +148,7 @@ def operation(self) -> OperationHandler[Input, Output]: ... class SyncOperationWithCallableInstance(_TestCase): - skip = "TODO(prerelease): update this test after decorator change" + skip = "TODO(preview): support callable instance" @nexusrpc.service class Contract: @@ -156,7 +156,7 @@ class Contract: @service_handler(service=Contract) class Service: - class _sync_operation_with_callable_instance: + class sync_operation_with_callable_instance: async def __call__( self, _handler: Any, @@ -164,8 +164,8 @@ async def __call__( input: Input, ) -> Output: ... - sync_operation_with_callable_instance = sync_operation( - _sync_operation_with_callable_instance() + _sync_operation_with_callable_instance = sync_operation( + sync_operation_with_callable_instance() ) expected_operations = { @@ -187,9 +187,6 @@ async def __call__( SyncOperationWithOperationHandlerNameOverride, ManualOperationWithContract, ManualOperationWithContractNameOverrideAndOperationHandlerNameOverride, - # TODO(prerelease): make callable instances work. Input type is not inferred due to - # signature differing from normal mathod. See also - # SyncHandlerHappyPathWithNonAsyncCallableInstance in temporal tests. SyncOperationWithCallableInstance, ], ) From 79e08a1f3041d90888dff98795ae3c7f9392f639 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 17:13:01 -0400 Subject: [PATCH 169/178] Cleanup --- ...ler_decorator_validates_against_service_contract.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 index df9ce3c..a071df1 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -79,7 +79,7 @@ async def op(self, ctx: StartOperationContext, input) -> None: ... error_message = None -class MissingOptionsAnnotation(_InterfaceImplementationTestCase): +class MissingContextAnnotation(_InterfaceImplementationTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[None, None] @@ -87,10 +87,9 @@ class Interface: class Impl: @sync_operation async def op( - # TODO(prerelease) isn't this supposed to be missing the ctx annotation? self, ctx: StartOperationContext, - input: str, + input: None, ) -> None: ... error_message = "is not compatible with the input type" @@ -238,7 +237,7 @@ async def op(self, ctx: StartOperationContext, input: Subclass) -> X: ... ValidImplWithoutTypeAnnotations, MissingOperation, MissingInputAnnotation, - MissingOptionsAnnotation, + MissingContextAnnotation, WrongOutputType, WrongOutputTypeWithNone, ValidImplWithNone, @@ -248,9 +247,6 @@ async def op(self, ctx: StartOperationContext, input: Subclass) -> X: ... OutputCovarianceImplOutputCannnotBeStrictSuperclass, InputContravarianceImplInputCanBeSameType, InputContravarianceImplInputCanBeSuperclass, - # ValidSubtyping, - # InvalidOutputSupertype, - # InvalidInputSubtype, ], ) def test_service_decorator_enforces_interface_implementation( From e6e2b95952aa4696b6e1c5f10df7b0554e36f862 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 18:46:48 -0400 Subject: [PATCH 170/178] Fix test assertion --- ...andler_decorator_validates_against_service_contract.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 index a071df1..7698493 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -86,13 +86,9 @@ class Interface: class Impl: @sync_operation - async def op( - self, - ctx: StartOperationContext, - input: None, - ) -> None: ... + async def op(self, ctx, input: None) -> None: ... - error_message = "is not compatible with the input type" + error_message = None class WrongOutputType(_InterfaceImplementationTestCase): From 901ac9b055158e4e46310dd329f9454bb9ea15fe Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 18:54:46 -0400 Subject: [PATCH 171/178] Skip test --- ...test_service_handler_from_user_instance.py | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/tests/handler/test_service_handler_from_user_instance.py b/tests/handler/test_service_handler_from_user_instance.py index 67054b0..83c93a1 100644 --- a/tests/handler/test_service_handler_from_user_instance.py +++ b/tests/handler/test_service_handler_from_user_instance.py @@ -1,28 +1,32 @@ from __future__ import annotations +import pytest + from nexusrpc.handler import StartOperationContext, service_handler -from nexusrpc.handler._core import ServiceHandler 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 + @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(), - ) + 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) + # service_handler = MyServiceHandlerWithCallableInstance() + # ServiceHandler.from_user_instance(service_handler) + pass From d2db8213fb9cc99b4f5f66e9102405c3588ba1c1 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 30 Jun 2025 19:11:43 -0400 Subject: [PATCH 172/178] Add no type annotations invalid usage test --- tests/handler/test_invalid_usage.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py index bab5ad3..8cdfb2a 100644 --- a/tests/handler/test_invalid_usage.py +++ b/tests/handler/test_invalid_usage.py @@ -15,6 +15,8 @@ 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, @@ -177,6 +179,17 @@ class SD: 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", [ @@ -189,6 +202,7 @@ class SD: AsyncioHandlerWithSyncioOperation, SyncioHandlerWithAsyncioOperation, ServiceDefinitionHasDuplicateMethodNames, + OperationHandlerNoInputOutputTypeAnnotationsWithoutServiceDefinition, ], ) def test_invalid_usage(test_case: _TestCase): From 112c2420f227ad38764dd7c8e9ab7ffa08f2af69 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 1 Jul 2025 16:15:58 -0400 Subject: [PATCH 173/178] Fix CI test matrix --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd6fcb1..14bf0b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: python-version: ['3.9', '3.13', '3.14'] - os: [ubuntu-latest, ubuntu-arm, macos-intel, macos-arm, windows-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Checkout repository From 84b288a7241a4648fe4f45d13fba71e2a4cde3a3 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 1 Jul 2025 17:45:54 -0400 Subject: [PATCH 174/178] Make OperationContext an ABC --- src/nexusrpc/handler/_common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nexusrpc/handler/_common.py b/src/nexusrpc/handler/_common.py index 134ec39..bac6b4e 100644 --- a/src/nexusrpc/handler/_common.py +++ b/src/nexusrpc/handler/_common.py @@ -1,5 +1,6 @@ from __future__ import annotations +from abc import ABC from dataclasses import dataclass, field from datetime import timedelta from typing import ( @@ -13,7 +14,7 @@ @dataclass(frozen=True) -class OperationContext: +class OperationContext(ABC): """Context for the execution of the requested operation method. Includes information from the request.""" From 00b7afab041108431e45d6193d626cc03cfb06d0 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Tue, 1 Jul 2025 17:56:41 -0400 Subject: [PATCH 175/178] Fix position of * in signature --- src/nexusrpc/handler/_decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nexusrpc/handler/_decorators.py b/src/nexusrpc/handler/_decorators.py index 8de8505..d8dd5d7 100644 --- a/src/nexusrpc/handler/_decorators.py +++ b/src/nexusrpc/handler/_decorators.py @@ -57,8 +57,8 @@ def service_handler( def service_handler( cls: Optional[Type[ServiceHandlerT]] = None, - service: Optional[Type[Any]] = None, *, + service: Optional[Type[Any]] = None, name: Optional[str] = None, ) -> Union[ Type[ServiceHandlerT], Callable[[Type[ServiceHandlerT]], Type[ServiceHandlerT]] From 37e863066425ba5c7d908ea05271e89b07cd3bce Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 2 Jul 2025 11:39:04 -0400 Subject: [PATCH 176/178] Bump version with explanatory note --- pyproject.toml | 7 ++++++- uv.lock | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7acf48e..684d09b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,11 @@ [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 = [ diff --git a/uv.lock b/uv.lock index 9d7b57a..52da269 100644 --- a/uv.lock +++ b/uv.lock @@ -453,7 +453,7 @@ wheels = [ [[package]] name = "nexus-rpc" -version = "0.1.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "typing-extensions" }, From 406e0a97cce32d003894da899977eefbf2569edc Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 2 Jul 2025 11:46:15 -0400 Subject: [PATCH 177/178] Capture and assert on warnings in tests --- ...collects_expected_operation_definitions.py | 63 +++++++++---------- ...ator_validates_against_service_contract.py | 48 +++++++++++--- 2 files changed, 68 insertions(+), 43 deletions(-) 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 index 332e948..4eaaedf 100644 --- a/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py +++ b/tests/handler/test_service_handler_decorator_collects_expected_operation_definitions.py @@ -34,7 +34,6 @@ class _TestCase: Service: Type[Any] expected_operations: dict[str, nexusrpc.Operation] Contract: Optional[Type[Any]] = None - skip: Optional[str] = None class ManualOperationHandler(_TestCase): @@ -147,35 +146,35 @@ def operation(self) -> OperationHandler[Input, Output]: ... } -class SyncOperationWithCallableInstance(_TestCase): - skip = "TODO(preview): support callable instance" - - @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, - ), - } +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( @@ -187,16 +186,12 @@ async def __call__( SyncOperationWithOperationHandlerNameOverride, ManualOperationWithContract, ManualOperationWithContractNameOverrideAndOperationHandlerNameOverride, - SyncOperationWithCallableInstance, ], ) @pytest.mark.asyncio async def test_collected_operation_definitions( test_case: Type[_TestCase], ): - if test_case.skip: - pytest.skip(test_case.skip) - service = get_service_definition(test_case.Service) assert isinstance(service, nexusrpc.ServiceDefinition) if test_case.Contract: 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 index 7698493..3f54784 100644 --- a/tests/handler/test_service_handler_decorator_validates_against_service_contract.py +++ b/tests/handler/test_service_handler_decorator_validates_against_service_contract.py @@ -1,3 +1,4 @@ +import warnings from typing import Any, Optional, Type import pytest @@ -49,9 +50,17 @@ class ValidImplWithoutTypeAnnotations(_InterfaceImplementationTestCase): class Interface: op: nexusrpc.Operation[int, str] - class Impl: - @sync_operation - async def op(self, ctx, input): ... + 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 @@ -72,9 +81,17 @@ class MissingInputAnnotation(_InterfaceImplementationTestCase): class Interface: op: nexusrpc.Operation[None, None] - class Impl: - @sync_operation - async def op(self, ctx: StartOperationContext, input) -> 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 @@ -84,9 +101,17 @@ class MissingContextAnnotation(_InterfaceImplementationTestCase): class Interface: op: nexusrpc.Operation[None, None] - class Impl: - @sync_operation - async def op(self, ctx, input: 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 @@ -254,6 +279,11 @@ def test_service_decorator_enforces_interface_implementation( 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) From d5411e30be753b3e672121d5209016f40a193681 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Wed, 2 Jul 2025 11:59:17 -0400 Subject: [PATCH 178/178] Add README note regarding versions --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 84d759e..2494091 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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 @@ -72,3 +74,7 @@ class MyNexusServiceHandler: 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.