From 6369069d890f6e6a6bbd6e324e6ce34bb04b9b91 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 14:29:35 -0800 Subject: [PATCH 1/9] Add test demonstrating covariant operation ouput types --- ...er_validates_service_handler_collection.py | 104 +++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index fbf2680..30b9c28 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -12,8 +12,11 @@ StartOperationContext, StartOperationResultSync, service_handler, + operation_handler, ) -from nexusrpc.handler._decorators import operation_handler +from nexusrpc import LazyValue, HandlerError, service, Operation, get_service_definition +from nexusrpc.handler._decorators import sync_operation +from tests.helpers import DummySerializer, TestOperationTaskCancellation def test_service_must_use_decorator(): @@ -63,3 +66,102 @@ class Service2: with pytest.raises(RuntimeError): _ = Handler([Service1(), Service2()]) + + +@pytest.mark.asyncio +async def test_operations_must_have_decorator(): + @service_handler + class TestService: + async def op(self, _ctx: StartOperationContext, input: str) -> str: + return input + + handler = Handler([TestService()]) + + with pytest.raises(HandlerError, match="has no operation 'op'"): + _ = await handler.start_operation( + StartOperationContext( + service=TestService.__name__, + operation=TestService.op.__name__, + headers={}, + request_id="test-req", + task_cancellation=TestOperationTaskCancellation(), + request_deadline=None, + callback_url=None, + ), + LazyValue( + serializer=DummySerializer(value="test"), + headers={}, + stream=None, + ), + ) + + +def test_covariance(): + class Foo: + def __init__(self) -> None: + self.name = "foo" + + class Bar(Foo): + def __init__(self) -> None: + super().__init__() + self.name = "bar" + + @service + class CovariantService: + op_handler: Operation[None, Foo] + inline: Operation[None, Foo] + + class ValidOperationHandler(OperationHandler[None, Foo]): + async def start( + self, ctx: StartOperationContext, input: None + ) -> StartOperationResultSync[Bar]: + return StartOperationResultSync(Bar()) + + async def cancel(self, ctx: CancelOperationContext, token: str) -> None: + pass + + class InvalidOperationHandler(OperationHandler[None, Bar]): + async def start( + self, ctx: StartOperationContext, input: None + ) -> StartOperationResultSync[Bar]: + return StartOperationResultSync(Bar()) + + async def cancel(self, ctx: CancelOperationContext, token: str) -> None: + pass + + # This service returns Bar but keeps the operation type as Foo + @service_handler(service=CovariantService) + class CovariantServiceImplValid: + @operation_handler + def op_handler(self) -> OperationHandler[None, Foo]: + return ValidOperationHandler() + + @sync_operation + async def inline(self, ctx: StartOperationContext, input: None) -> Foo: + return Bar() + + with pytest.raises(TypeError): + # This impl changes the output type in an obviously invalid way + # it raises as appropriate + @service_handler(service=CovariantService) + class ServiceImplInvalid: + @operation_handler + def op_handler(self) -> OperationHandler[None, Bar]: + return InvalidOperationHandler() + + @sync_operation + async def inline(self, ctx: StartOperationContext, input: None) -> int: + return 1 + + with pytest.raises(TypeError): + # This impl changes the output type to Bar instead of Foo for both operations + # it does not raise, though this would in Java/.Net + @service_handler(service=CovariantService) + class CovariantServiceImplInvalid: + @operation_handler + def op_handler(self) -> OperationHandler[None, Bar]: + return InvalidOperationHandler() + + @sync_operation + async def inline(self, ctx: StartOperationContext, input: None) -> Bar: + return Bar() From bc7b45bbb942980fed678b7fc6b4e595497284ea Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 17:34:52 -0800 Subject: [PATCH 2/9] Require exact type identity for operation handler input/output types Replace covariant/contravariant type checking with strict identity checks. Operation handler implementations must now declare the exact same input and output types as the service definition, rather than allowing subtype relationships. This simplifies type validation and makes the type contract more explicit. Also replace dataclass_transform with standard @dataclass decorator in tests. --- src/nexusrpc/handler/_operation_handler.py | 29 ++--- ...er_validates_service_handler_collection.py | 110 +++++++++-------- tests/handler/test_invalid_usage.py | 17 ++- tests/handler/test_request_routing.py | 10 +- ..._service_handler_decorator_requirements.py | 16 ++- ...rrectly_functioning_operation_factories.py | 10 +- ..._decorator_selects_correct_service_name.py | 10 +- ...ator_validates_against_service_contract.py | 115 +++++------------- ...tor_validates_duplicate_operation_names.py | 10 +- ..._creates_expected_operation_declaration.py | 10 +- ..._decorator_selects_correct_service_name.py | 10 +- .../test_service_decorator_validation.py | 10 +- .../test_service_definition_inheritance.py | 10 +- tests/test_get_input_and_output_types.py | 11 +- 14 files changed, 135 insertions(+), 243 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 7a06a51..ceeaed7 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -216,36 +216,25 @@ def validate_operation_handler_methods( # If handler's input_type is None (missing annotation), skip validation - the handler # relies on the service definition for type information. This supports handlers without # explicit type annotations when a service definition is provided. - if ( - op.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 is_subtype(op_defn.input_type, op.input_type) - ) + if op.input_type is not None and ( + op_defn.input_type is not op.input_type + # or is_subtype(op_defn.input_type, op.input_type) ): raise TypeError( - f"Operation '{op_defn.method_name}' in service '{service_cls}' " - f"has input type '{op.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." + f"OperationHandler input type mismatch for '{service_cls}.{op_defn.method_name}'" + f"expected {op_defn.input_type}, got {op.input_type}" ) # Output type is covariant: op handler output must be subclass of op defn output. # If handler's output_type is None (missing annotation), skip validation - the handler # relies on the service definition for type information. if ( - op.output_type is not None - and Any not in (op.output_type, op_defn.output_type) - and not is_subtype(op.output_type, op_defn.output_type) + op.output_type is not None and op.output_type is not op_defn.output_type + # and not is_subtype(op.output_type, op_defn.output_type) ): raise TypeError( - f"Operation '{op_defn.method_name}' in service '{service_cls}' " - f"has output type '{op.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." + f"OperationHandler output type mismatch for '{service_cls}.{op_defn.method_name}'" + f"expected {op_defn.output_type}, got {op.output_type}" ) if operation_handler_factories_by_method_name: raise ValueError( diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index 30b9c28..c63241e 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -14,7 +14,7 @@ service_handler, operation_handler, ) -from nexusrpc import LazyValue, HandlerError, service, Operation, get_service_definition +from nexusrpc import LazyValue, HandlerError, service, Operation from nexusrpc.handler._decorators import sync_operation from tests.helpers import DummySerializer, TestOperationTaskCancellation @@ -96,72 +96,74 @@ async def op(self, _ctx: StartOperationContext, input: str) -> str: ) -def test_covariance(): - class Foo: - def __init__(self) -> None: - self.name = "foo" +@pytest.mark.asyncio +async def test_handler_can_return_covariant_type(): + class Superclass: + pass - class Bar(Foo): - def __init__(self) -> None: - super().__init__() - self.name = "bar" + class Subclass(Superclass): + pass @service class CovariantService: - op_handler: Operation[None, Foo] - inline: Operation[None, Foo] - - class ValidOperationHandler(OperationHandler[None, Foo]): - async def start( - self, ctx: StartOperationContext, input: None - ) -> StartOperationResultSync[Bar]: - return StartOperationResultSync(Bar()) - - async def cancel(self, ctx: CancelOperationContext, token: str) -> None: - pass + op_handler: Operation[None, Superclass] + inline: Operation[None, Superclass] - class InvalidOperationHandler(OperationHandler[None, Bar]): + class ValidOperationHandler(OperationHandler[None, Superclass]): async def start( self, ctx: StartOperationContext, input: None - ) -> StartOperationResultSync[Bar]: - return StartOperationResultSync(Bar()) + ) -> StartOperationResultSync[Subclass]: + return StartOperationResultSync(Subclass()) async def cancel(self, ctx: CancelOperationContext, token: str) -> None: pass - # This service returns Bar but keeps the operation type as Foo @service_handler(service=CovariantService) - class CovariantServiceImplValid: + class CovariantServiceHandler: @operation_handler - def op_handler(self) -> OperationHandler[None, Foo]: + def op_handler(self) -> OperationHandler[None, Superclass]: return ValidOperationHandler() @sync_operation - async def inline(self, ctx: StartOperationContext, input: None) -> Foo: - return Bar() - - with pytest.raises(TypeError): - # This impl changes the output type in an obviously invalid way - # it raises as appropriate - @service_handler(service=CovariantService) - class ServiceImplInvalid: - @operation_handler - def op_handler(self) -> OperationHandler[None, Bar]: - return InvalidOperationHandler() - - @sync_operation - async def inline(self, ctx: StartOperationContext, input: None) -> int: - return 1 - - with pytest.raises(TypeError): - # This impl changes the output type to Bar instead of Foo for both operations - # it does not raise, though this would in Java/.Net - @service_handler(service=CovariantService) - class CovariantServiceImplInvalid: - @operation_handler - def op_handler(self) -> OperationHandler[None, Bar]: - return InvalidOperationHandler() - - @sync_operation - async def inline(self, ctx: StartOperationContext, input: None) -> Bar: - return Bar() + async def inline(self, ctx: StartOperationContext, input: None) -> Superclass: # pyright: ignore[reportUnusedParameter] + return Subclass() + + handler = Handler([CovariantServiceHandler()]) + + result = await handler.start_operation( + StartOperationContext( + service=CovariantService.__name__, + operation=CovariantService.op_handler.name, + headers={}, + request_id="test-req", + task_cancellation=TestOperationTaskCancellation(), + request_deadline=None, + callback_url=None, + ), + LazyValue( + serializer=DummySerializer(None), + headers={}, + stream=None, + ), + ) + assert type(result) is StartOperationResultSync + assert type(result.value) is Subclass + + result = await handler.start_operation( + StartOperationContext( + service=CovariantService.__name__, + operation=CovariantService.inline.name, + headers={}, + request_id="test-req", + task_cancellation=TestOperationTaskCancellation(), + request_deadline=None, + callback_url=None, + ), + LazyValue( + serializer=DummySerializer(None), + headers={}, + stream=None, + ), + ) + assert type(result) is StartOperationResultSync + assert type(result.value) is Subclass diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py index 2cd1277..303350b 100644 --- a/tests/handler/test_invalid_usage.py +++ b/tests/handler/test_invalid_usage.py @@ -3,10 +3,8 @@ handler implementations. """ -from typing import Any, Callable - import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc from nexusrpc.handler import ( @@ -19,15 +17,14 @@ from nexusrpc.handler._operation_handler import OperationHandler -@dataclass_transform() -class _BaseTestCase: - pass - - -class _TestCase(_BaseTestCase): - build: Callable[..., Any] +@dataclass() +class _TestCase: error_message: str + @staticmethod + def build(): + pass + class OperationHandlerOverridesNameInconsistentlyWithServiceDefinition(_TestCase): @staticmethod diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py index 64b54bc..a5dcbe8 100644 --- a/tests/handler/test_request_routing.py +++ b/tests/handler/test_request_routing.py @@ -1,7 +1,7 @@ from typing import Any, Callable, cast import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc from nexusrpc import LazyValue @@ -16,12 +16,8 @@ from tests.helpers import DummySerializer, TestOperationTaskCancellation -@dataclass_transform() -class _BaseTestCase: - pass - - -class _TestCase(_BaseTestCase): +@dataclass() +class _TestCase: UserService: type[Any] # (service_name, op_name) supported_request: tuple[str, str] diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index 80953ad..dcf1c55 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc from nexusrpc._util import get_service_definition @@ -15,12 +15,8 @@ from nexusrpc.handler._decorators import operation_handler -@dataclass_transform() -class _TestCase: - pass - - -class _DecoratorValidationTestCase(_TestCase): +@dataclass() +class _DecoratorValidationTestCase: UserService: type[Any] UserServiceHandler: type[Any] expected_error_message_pattern: str @@ -71,7 +67,8 @@ def test_decorator_validates_definition_compliance( service_handler(service=test_case.UserService)(test_case.UserServiceHandler) -class _ServiceHandlerInheritanceTestCase(_TestCase): +@dataclass() +class _ServiceHandlerInheritanceTestCase: UserServiceHandler: type[Any] expected_operations: set[str] @@ -134,7 +131,8 @@ def test_service_implementation_inheritance( ) -class _ServiceDefinitionInheritanceTestCase(_TestCase): +@dataclass() +class _ServiceDefinitionInheritanceTestCase: UserService: type[Any] expected_ops: set[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 bbe1c0c..7c488db 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 @@ -5,7 +5,7 @@ from typing import Any, Union, cast import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc from nexusrpc import InputT, OutputT @@ -26,12 +26,8 @@ from tests.helpers import TestOperationTaskCancellation -@dataclass_transform() -class _BaseTestCase: - pass - - -class _TestCase(_BaseTestCase): +@dataclass() +class _TestCase: Service: type[Any] expected_operation_factories: dict[str, Any] 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 786449f..04c7282 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 @@ -1,7 +1,7 @@ from typing import Optional import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc from nexusrpc._util import get_service_definition @@ -18,12 +18,8 @@ class ServiceInterfaceWithNameOverride: pass -@dataclass_transform() -class _BaseTestCase: - pass - - -class _NameOverrideTestCase(_BaseTestCase): +@dataclass() +class _NameOverrideTestCase: ServiceImpl: type expected_name: str expected_error: Optional[type[Exception]] = None 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 4be5c70..857b05f 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,7 +1,5 @@ -from typing import Any, Optional - import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc from nexusrpc.handler import ( @@ -11,18 +9,26 @@ ) -@dataclass_transform() -class _BaseTestCase: - pass - - -class _InterfaceImplementationTestCase(_BaseTestCase): +@dataclass() +class _InterfaceImplementationTestCase: Interface: type Impl: type - error_message: Optional[str] + error_message: str | None + + +class _InvalidInputTestCase(_InterfaceImplementationTestCase): + error_message = "OperationHandler input type mismatch" + + +class _InvalidOutputTestCase(_InterfaceImplementationTestCase): + error_message = "OperationHandler output type mismatch" -class ValidImpl(_InterfaceImplementationTestCase): +class _ValidTestCase(_InterfaceImplementationTestCase): + error_message = None + + +class ValidImpl(_ValidTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[None, None] @@ -33,8 +39,6 @@ class Impl: @sync_operation async def op(self, _ctx: StartOperationContext, _input: None) -> None: ... - error_message = None - class ValidImplWithEmptyInterfaceAndExtraOperation(_InterfaceImplementationTestCase): @nexusrpc.service @@ -50,7 +54,7 @@ def unrelated_method(self) -> None: ... error_message = "does not match an operation method name in the service definition" -class ValidImplWithoutTypeAnnotations(_InterfaceImplementationTestCase): +class ValidImplWithoutTypeAnnotations(_ValidTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[int, str] @@ -59,8 +63,6 @@ class Impl: @sync_operation async def op(self, ctx, input): ... # type: ignore[reportMissingParameterType] - error_message = None - class MissingOperation(_InterfaceImplementationTestCase): @nexusrpc.service @@ -73,7 +75,7 @@ class Impl: error_message = "does not implement an operation with method name 'op'" -class MissingInputAnnotation(_InterfaceImplementationTestCase): +class MissingInputAnnotation(_ValidTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[None, None] @@ -82,10 +84,8 @@ class Impl: @sync_operation async def op(self, ctx: StartOperationContext, input) -> None: ... # type: ignore[reportMissingParameterType] - error_message = None - -class MissingContextAnnotation(_InterfaceImplementationTestCase): +class MissingContextAnnotation(_ValidTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[None, None] @@ -94,10 +94,8 @@ class Impl: @sync_operation async def op(self, ctx, input: None) -> None: ... # type: ignore[reportMissingParameterType] - error_message = None - -class WrongOutputType(_InterfaceImplementationTestCase): +class WrongOutputType(_InvalidOutputTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[None, int] @@ -106,10 +104,8 @@ class Impl: @sync_operation async def op(self, _ctx: StartOperationContext, _input: None) -> str: ... - error_message = "is not compatible with the output type" - -class WrongOutputTypeWithNone(_InterfaceImplementationTestCase): +class WrongOutputTypeWithNone(_InvalidOutputTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[str, None] @@ -118,10 +114,8 @@ class Impl: @sync_operation async def op(self, _ctx: StartOperationContext, _input: str) -> str: ... - error_message = "is not compatible with the output type" - -class ValidImplWithNone(_InterfaceImplementationTestCase): +class ValidImplWithNone(_ValidTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[str, None] @@ -130,20 +124,6 @@ class Impl: @sync_operation async def op(self, _ctx: StartOperationContext, _input: str) -> None: ... - error_message = None - - -class MoreSpecificImplAllowed(_InterfaceImplementationTestCase): - @nexusrpc.service - class Interface: - op: nexusrpc.Operation[Any, Any] - - class Impl: - @sync_operation - async def op(self, _ctx: StartOperationContext, _input: str) -> str: ... - - error_message = None - class X: pass @@ -157,19 +137,7 @@ class Subclass(SuperClass): pass -class OutputCovarianceImplOutputCanBeSameType(_InterfaceImplementationTestCase): - @nexusrpc.service - class Interface: - op: nexusrpc.Operation[X, X] - - class Impl: - @sync_operation - async def op(self, _ctx: StartOperationContext, _input: X) -> X: ... - - error_message = None - - -class OutputCovarianceImplOutputCanBeSubclass(_InterfaceImplementationTestCase): +class OutputCovarianceImplOutputCannotBeSubclass(_InvalidOutputTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[X, SuperClass] @@ -178,12 +146,8 @@ class Impl: @sync_operation async def op(self, _ctx: StartOperationContext, _input: X) -> Subclass: ... - error_message = None - -class OutputCovarianceImplOutputCannnotBeStrictSuperclass( - _InterfaceImplementationTestCase -): +class OutputCovarianceImplOutputCannnotBeStrictSuperclass(_InvalidOutputTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[X, Subclass] @@ -192,22 +156,8 @@ class Impl: @sync_operation async def op(self, _ctx: StartOperationContext, _input: X) -> SuperClass: ... - error_message = "is not compatible with the output type" - - -class InputContravarianceImplInputCanBeSameType(_InterfaceImplementationTestCase): - @nexusrpc.service - class Interface: - op: nexusrpc.Operation[X, X] - - class Impl: - @sync_operation - async def op(self, _ctx: StartOperationContext, _input: X) -> X: ... - - error_message = None - -class InputContravarianceImplInputCanBeSuperclass(_InterfaceImplementationTestCase): +class InputContravarianceImplInputCannotBeSuperclass(_InvalidInputTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[Subclass, X] @@ -216,10 +166,8 @@ class Impl: @sync_operation async def op(self, _ctx: StartOperationContext, _input: SuperClass) -> X: ... - error_message = None - -class InputContravarianceImplInputCannotBeSubclass(_InterfaceImplementationTestCase): +class InputContravarianceImplInputCannotBeSubclass(_InvalidInputTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[SuperClass, X] @@ -228,8 +176,6 @@ class Impl: @sync_operation async def op(self, _ctx: StartOperationContext, _input: Subclass) -> X: ... - error_message = "is not compatible with the input type" - @pytest.mark.parametrize( "test_case", @@ -243,12 +189,9 @@ async def op(self, _ctx: StartOperationContext, _input: Subclass) -> X: ... WrongOutputType, WrongOutputTypeWithNone, ValidImplWithNone, - MoreSpecificImplAllowed, - OutputCovarianceImplOutputCanBeSameType, - OutputCovarianceImplOutputCanBeSubclass, + OutputCovarianceImplOutputCannotBeSubclass, OutputCovarianceImplOutputCannnotBeStrictSuperclass, - InputContravarianceImplInputCanBeSameType, - InputContravarianceImplInputCanBeSuperclass, + InputContravarianceImplInputCannotBeSuperclass, ], ) def test_service_decorator_enforces_interface_implementation( 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 52159d4..a8d33b2 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 @@ -1,7 +1,7 @@ from typing import Any import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass from nexusrpc.handler import ( OperationHandler, @@ -10,12 +10,8 @@ from nexusrpc.handler._decorators import operation_handler -@dataclass_transform() -class _BaseTestCase: - pass - - -class _TestCase(_BaseTestCase): +@dataclass() +class _TestCase: UserServiceHandler: type[Any] expected_error_message: str 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 1fa470c..c92b3f3 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 @@ -1,7 +1,7 @@ +from dataclasses import dataclass from typing import Any import pytest -from typing_extensions import dataclass_transform import nexusrpc from nexusrpc._util import get_service_definition @@ -11,12 +11,8 @@ class Output: pass -@dataclass_transform() -class _BaseTestCase: - pass - - -class OperationDeclarationTestCase(_BaseTestCase): +@dataclass() +class OperationDeclarationTestCase: Interface: type expected_ops: dict[str, tuple[type[Any], type[Any]]] 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 db25655..e2c09cf 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 @@ -1,16 +1,12 @@ import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc from nexusrpc._util import get_service_definition -@dataclass_transform() -class _BaseTestCase: - pass - - -class NameOverrideTestCase(_BaseTestCase): +@dataclass +class NameOverrideTestCase: Interface: type expected_name: str diff --git a/tests/service_definition/test_service_decorator_validation.py b/tests/service_definition/test_service_decorator_validation.py index 05f76a4..d777232 100644 --- a/tests/service_definition/test_service_decorator_validation.py +++ b/tests/service_definition/test_service_decorator_validation.py @@ -1,5 +1,5 @@ import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc @@ -8,12 +8,8 @@ class Output: pass -@dataclass_transform() -class _BaseTestCase: - pass - - -class _TestCase(_BaseTestCase): +@dataclass() +class _TestCase: Contract: type expected_error: Exception diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index d8af5b2..c974222 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -6,7 +6,7 @@ from typing import Any, Optional import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass import nexusrpc from nexusrpc import Operation, ServiceDefinition @@ -15,12 +15,8 @@ # See https://docs.python.org/3/howto/annotations.html -@dataclass_transform() -class _BaseTestCase: - pass - - -class _TestCase(_BaseTestCase): +@dataclass() +class _TestCase: UserService: type[Any] expected_operation_names: set[str] expected_error: Optional[str] = None diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index ff404eb..4a728c4 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -7,9 +7,8 @@ get_args, get_origin, ) - import pytest -from typing_extensions import dataclass_transform +from dataclasses import dataclass from nexusrpc.handler import StartOperationContext from nexusrpc.handler._util import get_start_method_input_and_output_type_annotations @@ -23,12 +22,8 @@ class Output: pass -@dataclass_transform() -class _BaseTestCase: - pass - - -class _TestCase(_BaseTestCase): +@dataclass() +class _TestCase: start: Callable[..., Any] expected_types: tuple[Any, Any] From 95b032c552f811111cdb93a354c2de569c6abc9f Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 17:43:40 -0800 Subject: [PATCH 3/9] Update comments around operation input/output type checking --- src/nexusrpc/handler/_operation_handler.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index ceeaed7..a1a8b54 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -212,26 +212,18 @@ def validate_operation_handler_methods( 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 input. # If handler's input_type is None (missing annotation), skip validation - the handler # relies on the service definition for type information. This supports handlers without # explicit type annotations when a service definition is provided. - if op.input_type is not None and ( - op_defn.input_type is not op.input_type - # or is_subtype(op_defn.input_type, op.input_type) - ): + if op.input_type is not None and (op_defn.input_type is not op.input_type): raise TypeError( f"OperationHandler input type mismatch for '{service_cls}.{op_defn.method_name}'" f"expected {op_defn.input_type}, got {op.input_type}" ) - # Output type is covariant: op handler output must be subclass of op defn output. # If handler's output_type is None (missing annotation), skip validation - the handler # relies on the service definition for type information. - if ( - op.output_type is not None and op.output_type is not op_defn.output_type - # and not is_subtype(op.output_type, op_defn.output_type) - ): + if op.output_type is not None and op.output_type is not op_defn.output_type: raise TypeError( f"OperationHandler output type mismatch for '{service_cls}.{op_defn.method_name}'" f"expected {op_defn.output_type}, got {op.output_type}" From a8a67470f9e683780ea8b836ccb2d217d959851d Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 17:54:02 -0800 Subject: [PATCH 4/9] Remove is_subtype function and fix import ordering Remove the now-unused is_subtype function since type checking requires exact type identity. Also fix import ordering in test files to follow PEP 8 conventions. Co-Authored-By: Claude Opus 4.5 --- src/nexusrpc/_util.py | 9 --- src/nexusrpc/handler/_operation_handler.py | 1 - ...er_validates_service_handler_collection.py | 78 +------------------ tests/handler/test_invalid_usage.py | 3 +- tests/handler/test_request_routing.py | 2 +- ..._service_handler_decorator_requirements.py | 2 +- ...rrectly_functioning_operation_factories.py | 2 +- ..._decorator_selects_correct_service_name.py | 2 +- ...ator_validates_against_service_contract.py | 3 +- ...tor_validates_duplicate_operation_names.py | 2 +- ..._decorator_selects_correct_service_name.py | 3 +- .../test_service_decorator_validation.py | 3 +- .../test_service_definition_inheritance.py | 2 +- tests/test_get_input_and_output_types.py | 3 +- 14 files changed, 18 insertions(+), 97 deletions(-) diff --git a/src/nexusrpc/_util.py b/src/nexusrpc/_util.py index 776079a..7e89137 100644 --- a/src/nexusrpc/_util.py +++ b/src/nexusrpc/_util.py @@ -2,7 +2,6 @@ import functools import inspect -import typing from collections.abc import Awaitable from typing import TYPE_CHECKING, Any, Callable, Optional @@ -142,14 +141,6 @@ 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 a1a8b54..d27ca51 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -12,7 +12,6 @@ get_operation_factory, is_async_callable, is_callable, - is_subtype, ) from ._common import ( diff --git a/tests/handler/test_handler_validates_service_handler_collection.py b/tests/handler/test_handler_validates_service_handler_collection.py index c63241e..2b1b8ec 100644 --- a/tests/handler/test_handler_validates_service_handler_collection.py +++ b/tests/handler/test_handler_validates_service_handler_collection.py @@ -5,17 +5,16 @@ import pytest +from nexusrpc import HandlerError, LazyValue from nexusrpc.handler import ( CancelOperationContext, Handler, OperationHandler, StartOperationContext, StartOperationResultSync, - service_handler, operation_handler, + service_handler, ) -from nexusrpc import LazyValue, HandlerError, service, Operation -from nexusrpc.handler._decorators import sync_operation from tests.helpers import DummySerializer, TestOperationTaskCancellation @@ -94,76 +93,3 @@ async def op(self, _ctx: StartOperationContext, input: str) -> str: stream=None, ), ) - - -@pytest.mark.asyncio -async def test_handler_can_return_covariant_type(): - class Superclass: - pass - - class Subclass(Superclass): - pass - - @service - class CovariantService: - op_handler: Operation[None, Superclass] - inline: Operation[None, Superclass] - - class ValidOperationHandler(OperationHandler[None, Superclass]): - async def start( - self, ctx: StartOperationContext, input: None - ) -> StartOperationResultSync[Subclass]: - return StartOperationResultSync(Subclass()) - - async def cancel(self, ctx: CancelOperationContext, token: str) -> None: - pass - - @service_handler(service=CovariantService) - class CovariantServiceHandler: - @operation_handler - def op_handler(self) -> OperationHandler[None, Superclass]: - return ValidOperationHandler() - - @sync_operation - async def inline(self, ctx: StartOperationContext, input: None) -> Superclass: # pyright: ignore[reportUnusedParameter] - return Subclass() - - handler = Handler([CovariantServiceHandler()]) - - result = await handler.start_operation( - StartOperationContext( - service=CovariantService.__name__, - operation=CovariantService.op_handler.name, - headers={}, - request_id="test-req", - task_cancellation=TestOperationTaskCancellation(), - request_deadline=None, - callback_url=None, - ), - LazyValue( - serializer=DummySerializer(None), - headers={}, - stream=None, - ), - ) - assert type(result) is StartOperationResultSync - assert type(result.value) is Subclass - - result = await handler.start_operation( - StartOperationContext( - service=CovariantService.__name__, - operation=CovariantService.inline.name, - headers={}, - request_id="test-req", - task_cancellation=TestOperationTaskCancellation(), - request_deadline=None, - callback_url=None, - ), - LazyValue( - serializer=DummySerializer(None), - headers={}, - stream=None, - ), - ) - assert type(result) is StartOperationResultSync - assert type(result.value) is Subclass diff --git a/tests/handler/test_invalid_usage.py b/tests/handler/test_invalid_usage.py index 303350b..5ffa31b 100644 --- a/tests/handler/test_invalid_usage.py +++ b/tests/handler/test_invalid_usage.py @@ -3,9 +3,10 @@ handler implementations. """ -import pytest from dataclasses import dataclass +import pytest + import nexusrpc from nexusrpc.handler import ( Handler, diff --git a/tests/handler/test_request_routing.py b/tests/handler/test_request_routing.py index a5dcbe8..04a4a75 100644 --- a/tests/handler/test_request_routing.py +++ b/tests/handler/test_request_routing.py @@ -1,7 +1,7 @@ +from dataclasses import dataclass from typing import Any, Callable, cast import pytest -from dataclasses import dataclass import nexusrpc from nexusrpc import LazyValue diff --git a/tests/handler/test_service_handler_decorator_requirements.py b/tests/handler/test_service_handler_decorator_requirements.py index dcf1c55..8548e99 100644 --- a/tests/handler/test_service_handler_decorator_requirements.py +++ b/tests/handler/test_service_handler_decorator_requirements.py @@ -1,9 +1,9 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any import pytest -from dataclasses import dataclass import nexusrpc from nexusrpc._util import get_service_definition 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 7c488db..cd7570a 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 @@ -2,10 +2,10 @@ Test that operation decorators result in operation factories that return the correct result. """ +from dataclasses import dataclass from typing import Any, Union, cast import pytest -from dataclasses import dataclass import nexusrpc from nexusrpc import InputT, OutputT 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 04c7282..b02897b 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 @@ -1,7 +1,7 @@ +from dataclasses import dataclass from typing import Optional import pytest -from dataclasses import dataclass import nexusrpc from nexusrpc._util import get_service_definition 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 857b05f..7ce26ed 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,6 +1,7 @@ -import pytest from dataclasses import dataclass +import pytest + import nexusrpc from nexusrpc.handler import ( StartOperationContext, 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 a8d33b2..4ee52a4 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 @@ -1,7 +1,7 @@ +from dataclasses import dataclass from typing import Any import pytest -from dataclasses import dataclass from nexusrpc.handler import ( OperationHandler, 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 e2c09cf..8227013 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 @@ -1,6 +1,7 @@ -import pytest from dataclasses import dataclass +import pytest + import nexusrpc from nexusrpc._util import get_service_definition diff --git a/tests/service_definition/test_service_decorator_validation.py b/tests/service_definition/test_service_decorator_validation.py index d777232..fa5985d 100644 --- a/tests/service_definition/test_service_decorator_validation.py +++ b/tests/service_definition/test_service_decorator_validation.py @@ -1,6 +1,7 @@ -import pytest from dataclasses import dataclass +import pytest + import nexusrpc diff --git a/tests/service_definition/test_service_definition_inheritance.py b/tests/service_definition/test_service_definition_inheritance.py index c974222..e6974b4 100644 --- a/tests/service_definition/test_service_definition_inheritance.py +++ b/tests/service_definition/test_service_definition_inheritance.py @@ -3,10 +3,10 @@ # 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 dataclasses import dataclass from typing import Any, Optional import pytest -from dataclasses import dataclass import nexusrpc from nexusrpc import Operation, ServiceDefinition diff --git a/tests/test_get_input_and_output_types.py b/tests/test_get_input_and_output_types.py index 4a728c4..ee06b3b 100644 --- a/tests/test_get_input_and_output_types.py +++ b/tests/test_get_input_and_output_types.py @@ -1,5 +1,6 @@ import warnings from collections.abc import Awaitable +from dataclasses import dataclass from typing import ( Any, Callable, @@ -7,8 +8,8 @@ get_args, get_origin, ) + import pytest -from dataclasses import dataclass from nexusrpc.handler import StartOperationContext from nexusrpc.handler._util import get_start_method_input_and_output_type_annotations From b9bdcb6dcae52a19f1bcf764ac939cc72df1141a Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 18:02:16 -0800 Subject: [PATCH 5/9] Fix error message formatting and typo in test class name - Add missing colon and space in type mismatch error messages - Fix typo: OutputCovarianceImplOutputCannnotBeStrictSuperclass -> OutputCovarianceImplOutputCannotBeStrictSuperclass Co-Authored-By: Claude Opus 4.5 --- src/nexusrpc/handler/_operation_handler.py | 4 ++-- ...ce_handler_decorator_validates_against_service_contract.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index d27ca51..911b8ec 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -216,7 +216,7 @@ def validate_operation_handler_methods( # explicit type annotations when a service definition is provided. if op.input_type is not None and (op_defn.input_type is not op.input_type): raise TypeError( - f"OperationHandler input type mismatch for '{service_cls}.{op_defn.method_name}'" + f"OperationHandler input type mismatch for '{service_cls}.{op_defn.method_name}': " f"expected {op_defn.input_type}, got {op.input_type}" ) @@ -224,7 +224,7 @@ def validate_operation_handler_methods( # relies on the service definition for type information. if op.output_type is not None and op.output_type is not op_defn.output_type: raise TypeError( - f"OperationHandler output type mismatch for '{service_cls}.{op_defn.method_name}'" + f"OperationHandler output type mismatch for '{service_cls}.{op_defn.method_name}': " f"expected {op_defn.output_type}, got {op.output_type}" ) if operation_handler_factories_by_method_name: 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 7ce26ed..e10944d 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 @@ -148,7 +148,7 @@ class Impl: async def op(self, _ctx: StartOperationContext, _input: X) -> Subclass: ... -class OutputCovarianceImplOutputCannnotBeStrictSuperclass(_InvalidOutputTestCase): +class OutputCovarianceImplOutputCannotBeStrictSuperclass(_InvalidOutputTestCase): @nexusrpc.service class Interface: op: nexusrpc.Operation[X, Subclass] @@ -191,7 +191,7 @@ async def op(self, _ctx: StartOperationContext, _input: Subclass) -> X: ... WrongOutputTypeWithNone, ValidImplWithNone, OutputCovarianceImplOutputCannotBeSubclass, - OutputCovarianceImplOutputCannnotBeStrictSuperclass, + OutputCovarianceImplOutputCannotBeStrictSuperclass, InputContravarianceImplInputCannotBeSuperclass, ], ) From b3688e46742ec8317f9fd7b5c3c05beaf19ce9a2 Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 18:11:11 -0800 Subject: [PATCH 6/9] Add tests for generic type comparison in operation handlers Adds test cases using parameterized types (list[int], dict[str, bool]) to validate that equality comparison (!=) works correctly for type checking. This ensures generic types are properly compared since they create new objects on each evaluation, making identity comparison (is) unreliable. Also adds missing InputContravarianceImplInputCannotBeSubclass to the parametrize list. Co-Authored-By: Claude Opus 4.5 --- ...ator_validates_against_service_contract.py | 44 +++++++++++++++++++ 1 file changed, 44 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 e10944d..f4c5825 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 @@ -178,6 +178,46 @@ class Impl: async def op(self, _ctx: StartOperationContext, _input: Subclass) -> X: ... +class ValidImplWithGenericTypes(_ValidTestCase): + """Validates that generic types work with equality comparison.""" + + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[list[int], dict[str, bool]] + + class Impl: + @sync_operation + async def op( + self, _ctx: StartOperationContext, _input: list[int] + ) -> dict[str, bool]: ... + + +class InvalidImplWithWrongGenericInputType(_InvalidInputTestCase): + """Validates that mismatched generic input types are caught.""" + + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[list[int], str] + + class Impl: + @sync_operation + async def op(self, _ctx: StartOperationContext, _input: list[str]) -> str: ... + + +class InvalidImplWithWrongGenericOutputType(_InvalidOutputTestCase): + """Validates that mismatched generic output types are caught.""" + + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[str, dict[str, int]] + + class Impl: + @sync_operation + async def op( + self, _ctx: StartOperationContext, _input: str + ) -> dict[str, str]: ... + + @pytest.mark.parametrize( "test_case", [ @@ -193,6 +233,10 @@ async def op(self, _ctx: StartOperationContext, _input: Subclass) -> X: ... OutputCovarianceImplOutputCannotBeSubclass, OutputCovarianceImplOutputCannotBeStrictSuperclass, InputContravarianceImplInputCannotBeSuperclass, + InputContravarianceImplInputCannotBeSubclass, + ValidImplWithGenericTypes, + InvalidImplWithWrongGenericInputType, + InvalidImplWithWrongGenericOutputType, ], ) def test_service_decorator_enforces_interface_implementation( From 7d84ce1417779e910e01e879a5c9524a2e8d41ba Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 18:13:21 -0800 Subject: [PATCH 7/9] use equality checks instead of identity checks --- src/nexusrpc/handler/_operation_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index 911b8ec..a723337 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -214,7 +214,7 @@ def validate_operation_handler_methods( # If handler's input_type is None (missing annotation), skip validation - the handler # relies on the service definition for type information. This supports handlers without # explicit type annotations when a service definition is provided. - if op.input_type is not None and (op_defn.input_type is not op.input_type): + if op.input_type is not None and op_defn.input_type != op.input_type: raise TypeError( f"OperationHandler input type mismatch for '{service_cls}.{op_defn.method_name}': " f"expected {op_defn.input_type}, got {op.input_type}" @@ -222,7 +222,7 @@ def validate_operation_handler_methods( # If handler's output_type is None (missing annotation), skip validation - the handler # relies on the service definition for type information. - if op.output_type is not None and op.output_type is not op_defn.output_type: + if op.output_type is not None and op.output_type != op_defn.output_type: raise TypeError( f"OperationHandler output type mismatch for '{service_cls}.{op_defn.method_name}': " f"expected {op_defn.output_type}, got {op.output_type}" From 6ec69a12f372e8ab18b3ba980fe29807cf6e604e Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 18:16:54 -0800 Subject: [PATCH 8/9] Add in new test file --- ...test_operation_handler_runtime_behavior.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/handler/test_operation_handler_runtime_behavior.py diff --git a/tests/handler/test_operation_handler_runtime_behavior.py b/tests/handler/test_operation_handler_runtime_behavior.py new file mode 100644 index 0000000..8689a35 --- /dev/null +++ b/tests/handler/test_operation_handler_runtime_behavior.py @@ -0,0 +1,95 @@ +""" +Test runtime behavior of operation handlers invoked through Handler.start_operation(). + +This file tests actual execution behavior, distinct from: +- Decoration-time validation (test_service_handler_decorator_validates_against_service_contract.py) +- Handler constructor validation (test_handler_validates_service_handler_collection.py) +""" + +import pytest + +from nexusrpc import LazyValue, Operation, service +from nexusrpc.handler import ( + CancelOperationContext, + Handler, + OperationHandler, + StartOperationContext, + StartOperationResultSync, + operation_handler, + service_handler, +) +from nexusrpc.handler._decorators import sync_operation +from tests.helpers import DummySerializer, TestOperationTaskCancellation + + +@pytest.mark.asyncio +async def test_handler_can_return_covariant_type(): + class Superclass: + pass + + class Subclass(Superclass): + pass + + @service + class CovariantService: + op_handler: Operation[None, Superclass] + inline: Operation[None, Superclass] + + class ValidOperationHandler(OperationHandler[None, Superclass]): + async def start( + self, ctx: StartOperationContext, input: None + ) -> StartOperationResultSync[Subclass]: + return StartOperationResultSync(Subclass()) + + async def cancel(self, ctx: CancelOperationContext, token: str) -> None: + pass + + @service_handler(service=CovariantService) + class CovariantServiceHandler: + @operation_handler + def op_handler(self) -> OperationHandler[None, Superclass]: + return ValidOperationHandler() + + @sync_operation + async def inline(self, ctx: StartOperationContext, input: None) -> Superclass: # pyright: ignore[reportUnusedParameter] + return Subclass() + + handler = Handler([CovariantServiceHandler()]) + + result = await handler.start_operation( + StartOperationContext( + service=CovariantService.__name__, + operation=CovariantService.op_handler.name, + headers={}, + request_id="test-req", + task_cancellation=TestOperationTaskCancellation(), + request_deadline=None, + callback_url=None, + ), + LazyValue( + serializer=DummySerializer(None), + headers={}, + stream=None, + ), + ) + assert type(result) is StartOperationResultSync + assert type(result.value) is Subclass + + result = await handler.start_operation( + StartOperationContext( + service=CovariantService.__name__, + operation=CovariantService.inline.name, + headers={}, + request_id="test-req", + task_cancellation=TestOperationTaskCancellation(), + request_deadline=None, + callback_url=None, + ), + LazyValue( + serializer=DummySerializer(None), + headers={}, + stream=None, + ), + ) + assert type(result) is StartOperationResultSync + assert type(result.value) is Subclass From 1f13d4cc7ccd824a31141df9c1dc900fb208681c Mon Sep 17 00:00:00 2001 From: Alex Mazzeo Date: Fri, 16 Jan 2026 18:21:01 -0800 Subject: [PATCH 9/9] Update docstring and add tests for Any type behavior - Fix stale docstring that described covariance/contravariance - Add tests clarifying that Any requires exact match, not wildcard Co-Authored-By: Claude Opus 4.5 --- src/nexusrpc/handler/_operation_handler.py | 5 +-- ...ator_validates_against_service_contract.py | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/nexusrpc/handler/_operation_handler.py b/src/nexusrpc/handler/_operation_handler.py index a723337..181fa67 100644 --- a/src/nexusrpc/handler/_operation_handler.py +++ b/src/nexusrpc/handler/_operation_handler.py @@ -179,9 +179,8 @@ def validate_operation_handler_methods( 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. + 2. The input and output types of the handler method must exactly match the types + declared in the service definition. """ operation_handler_factories_by_method_name = ( operation_handler_factories_by_method_name.copy() 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 f4c5825..65404a9 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,5 @@ from dataclasses import dataclass +from typing import Any import pytest @@ -218,6 +219,42 @@ async def op( ) -> dict[str, str]: ... +class ValidImplWithAnyTypes(_ValidTestCase): + """Validates that Any types require exact match (Any == Any).""" + + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[Any, Any] + + class Impl: + @sync_operation + async def op(self, _ctx: StartOperationContext, _input: Any) -> Any: ... + + +class InvalidImplWithAnyInputButSpecificImpl(_InvalidInputTestCase): + """Validates that Any in interface does not act as wildcard for input types.""" + + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[Any, str] + + class Impl: + @sync_operation + async def op(self, _ctx: StartOperationContext, _input: str) -> str: ... + + +class InvalidImplWithAnyOutputButSpecificImpl(_InvalidOutputTestCase): + """Validates that Any in interface does not act as wildcard for output types.""" + + @nexusrpc.service + class Interface: + op: nexusrpc.Operation[str, Any] + + class Impl: + @sync_operation + async def op(self, _ctx: StartOperationContext, _input: str) -> str: ... + + @pytest.mark.parametrize( "test_case", [ @@ -237,6 +274,9 @@ async def op( ValidImplWithGenericTypes, InvalidImplWithWrongGenericInputType, InvalidImplWithWrongGenericOutputType, + ValidImplWithAnyTypes, + InvalidImplWithAnyInputButSpecificImpl, + InvalidImplWithAnyOutputButSpecificImpl, ], ) def test_service_decorator_enforces_interface_implementation(