Skip to content

Commit e33ab04

Browse files
Require type equality for operation handler input/output types (#44)
* Add test demonstrating covariant operation ouput types * 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. * Update comments around operation input/output type checking * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * use equality checks instead of identity checks * Add in new test file * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0f1cf25 commit e33ab04

16 files changed

+277
-192
lines changed

src/nexusrpc/_util.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import functools
44
import inspect
5-
import typing
65
from collections.abc import Awaitable
76
from typing import TYPE_CHECKING, Any, Callable, Optional
87

@@ -142,14 +141,6 @@ def get_callable_name(fn: Callable[..., Any]) -> str:
142141
return method_name
143142

144143

145-
def is_subtype(type1: type[Any], type2: type[Any]) -> bool:
146-
# Note that issubclass() argument 2 cannot be a parameterized generic
147-
# TODO(nexus-preview): review desired type compatibility logic
148-
if type1 == type2:
149-
return True
150-
return issubclass(type1, typing.get_origin(type2) or type2)
151-
152-
153144
# See
154145
# https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older
155146

src/nexusrpc/handler/_operation_handler.py

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
get_operation_factory,
1313
is_async_callable,
1414
is_callable,
15-
is_subtype,
1615
)
1716

1817
from ._common import (
@@ -180,9 +179,8 @@ def validate_operation_handler_methods(
180179
1. There must be a method in ``user_methods`` whose method name matches the method
181180
name from the service definition.
182181
183-
2. The input and output types of the user method must be such that the user method
184-
is a subtype of the operation defined in the service definition, i.e. respecting
185-
input type contravariance and output type covariance.
182+
2. The input and output types of the handler method must exactly match the types
183+
declared in the service definition.
186184
"""
187185
operation_handler_factories_by_method_name = (
188186
operation_handler_factories_by_method_name.copy()
@@ -212,40 +210,21 @@ def validate_operation_handler_methods(
212210
f"is '{op_defn.name}'. Operation handlers may not override the name of an operation "
213211
f"in the service definition."
214212
)
215-
# Input type is contravariant: op handler input must be superclass of op defn input.
216213
# If handler's input_type is None (missing annotation), skip validation - the handler
217214
# relies on the service definition for type information. This supports handlers without
218215
# explicit type annotations when a service definition is provided.
219-
if (
220-
op.input_type is not None
221-
and Any not in (op.input_type, op_defn.input_type)
222-
and not (
223-
op_defn.input_type == op.input_type
224-
or is_subtype(op_defn.input_type, op.input_type)
225-
)
226-
):
216+
if op.input_type is not None and op_defn.input_type != op.input_type:
227217
raise TypeError(
228-
f"Operation '{op_defn.method_name}' in service '{service_cls}' "
229-
f"has input type '{op.input_type}', which is not "
230-
f"compatible with the input type '{op_defn.input_type}' in interface "
231-
f"'{service_definition.name}'. The input type must be the same as or a "
232-
f"superclass of the operation definition input type."
218+
f"OperationHandler input type mismatch for '{service_cls}.{op_defn.method_name}': "
219+
f"expected {op_defn.input_type}, got {op.input_type}"
233220
)
234221

235-
# Output type is covariant: op handler output must be subclass of op defn output.
236222
# If handler's output_type is None (missing annotation), skip validation - the handler
237223
# relies on the service definition for type information.
238-
if (
239-
op.output_type is not None
240-
and Any not in (op.output_type, op_defn.output_type)
241-
and not is_subtype(op.output_type, op_defn.output_type)
242-
):
224+
if op.output_type is not None and op.output_type != op_defn.output_type:
243225
raise TypeError(
244-
f"Operation '{op_defn.method_name}' in service '{service_cls}' "
245-
f"has output type '{op.output_type}', which is not "
246-
f"compatible with the output type '{op_defn.output_type}' in interface "
247-
f" '{service_definition}'. The output type must be the same as or a "
248-
f"subclass of the operation definition output type."
226+
f"OperationHandler output type mismatch for '{service_cls}.{op_defn.method_name}': "
227+
f"expected {op_defn.output_type}, got {op.output_type}"
249228
)
250229
if operation_handler_factories_by_method_name:
251230
raise ValueError(

tests/handler/test_handler_validates_service_handler_collection.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55

66
import pytest
77

8+
from nexusrpc import HandlerError, LazyValue
89
from nexusrpc.handler import (
910
CancelOperationContext,
1011
Handler,
1112
OperationHandler,
1213
StartOperationContext,
1314
StartOperationResultSync,
15+
operation_handler,
1416
service_handler,
1517
)
16-
from nexusrpc.handler._decorators import operation_handler
18+
from tests.helpers import DummySerializer, TestOperationTaskCancellation
1719

1820

1921
def test_service_must_use_decorator():
@@ -63,3 +65,31 @@ class Service2:
6365

6466
with pytest.raises(RuntimeError):
6567
_ = Handler([Service1(), Service2()])
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_operations_must_have_decorator():
72+
@service_handler
73+
class TestService:
74+
async def op(self, _ctx: StartOperationContext, input: str) -> str:
75+
return input
76+
77+
handler = Handler([TestService()])
78+
79+
with pytest.raises(HandlerError, match="has no operation 'op'"):
80+
_ = await handler.start_operation(
81+
StartOperationContext(
82+
service=TestService.__name__,
83+
operation=TestService.op.__name__,
84+
headers={},
85+
request_id="test-req",
86+
task_cancellation=TestOperationTaskCancellation(),
87+
request_deadline=None,
88+
callback_url=None,
89+
),
90+
LazyValue(
91+
serializer=DummySerializer(value="test"),
92+
headers={},
93+
stream=None,
94+
),
95+
)

tests/handler/test_invalid_usage.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
handler implementations.
44
"""
55

6-
from typing import Any, Callable
6+
from dataclasses import dataclass
77

88
import pytest
9-
from typing_extensions import dataclass_transform
109

1110
import nexusrpc
1211
from nexusrpc.handler import (
@@ -19,15 +18,14 @@
1918
from nexusrpc.handler._operation_handler import OperationHandler
2019

2120

22-
@dataclass_transform()
23-
class _BaseTestCase:
24-
pass
25-
26-
27-
class _TestCase(_BaseTestCase):
28-
build: Callable[..., Any]
21+
@dataclass()
22+
class _TestCase:
2923
error_message: str
3024

25+
@staticmethod
26+
def build():
27+
pass
28+
3129

3230
class OperationHandlerOverridesNameInconsistentlyWithServiceDefinition(_TestCase):
3331
@staticmethod
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""
2+
Test runtime behavior of operation handlers invoked through Handler.start_operation().
3+
4+
This file tests actual execution behavior, distinct from:
5+
- Decoration-time validation (test_service_handler_decorator_validates_against_service_contract.py)
6+
- Handler constructor validation (test_handler_validates_service_handler_collection.py)
7+
"""
8+
9+
import pytest
10+
11+
from nexusrpc import LazyValue, Operation, service
12+
from nexusrpc.handler import (
13+
CancelOperationContext,
14+
Handler,
15+
OperationHandler,
16+
StartOperationContext,
17+
StartOperationResultSync,
18+
operation_handler,
19+
service_handler,
20+
)
21+
from nexusrpc.handler._decorators import sync_operation
22+
from tests.helpers import DummySerializer, TestOperationTaskCancellation
23+
24+
25+
@pytest.mark.asyncio
26+
async def test_handler_can_return_covariant_type():
27+
class Superclass:
28+
pass
29+
30+
class Subclass(Superclass):
31+
pass
32+
33+
@service
34+
class CovariantService:
35+
op_handler: Operation[None, Superclass]
36+
inline: Operation[None, Superclass]
37+
38+
class ValidOperationHandler(OperationHandler[None, Superclass]):
39+
async def start(
40+
self, ctx: StartOperationContext, input: None
41+
) -> StartOperationResultSync[Subclass]:
42+
return StartOperationResultSync(Subclass())
43+
44+
async def cancel(self, ctx: CancelOperationContext, token: str) -> None:
45+
pass
46+
47+
@service_handler(service=CovariantService)
48+
class CovariantServiceHandler:
49+
@operation_handler
50+
def op_handler(self) -> OperationHandler[None, Superclass]:
51+
return ValidOperationHandler()
52+
53+
@sync_operation
54+
async def inline(self, ctx: StartOperationContext, input: None) -> Superclass: # pyright: ignore[reportUnusedParameter]
55+
return Subclass()
56+
57+
handler = Handler([CovariantServiceHandler()])
58+
59+
result = await handler.start_operation(
60+
StartOperationContext(
61+
service=CovariantService.__name__,
62+
operation=CovariantService.op_handler.name,
63+
headers={},
64+
request_id="test-req",
65+
task_cancellation=TestOperationTaskCancellation(),
66+
request_deadline=None,
67+
callback_url=None,
68+
),
69+
LazyValue(
70+
serializer=DummySerializer(None),
71+
headers={},
72+
stream=None,
73+
),
74+
)
75+
assert type(result) is StartOperationResultSync
76+
assert type(result.value) is Subclass
77+
78+
result = await handler.start_operation(
79+
StartOperationContext(
80+
service=CovariantService.__name__,
81+
operation=CovariantService.inline.name,
82+
headers={},
83+
request_id="test-req",
84+
task_cancellation=TestOperationTaskCancellation(),
85+
request_deadline=None,
86+
callback_url=None,
87+
),
88+
LazyValue(
89+
serializer=DummySerializer(None),
90+
headers={},
91+
stream=None,
92+
),
93+
)
94+
assert type(result) is StartOperationResultSync
95+
assert type(result.value) is Subclass

tests/handler/test_request_routing.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
from dataclasses import dataclass
12
from typing import Any, Callable, cast
23

34
import pytest
4-
from typing_extensions import dataclass_transform
55

66
import nexusrpc
77
from nexusrpc import LazyValue
@@ -16,12 +16,8 @@
1616
from tests.helpers import DummySerializer, TestOperationTaskCancellation
1717

1818

19-
@dataclass_transform()
20-
class _BaseTestCase:
21-
pass
22-
23-
24-
class _TestCase(_BaseTestCase):
19+
@dataclass()
20+
class _TestCase:
2521
UserService: type[Any]
2622
# (service_name, op_name)
2723
supported_request: tuple[str, str]

tests/handler/test_service_handler_decorator_requirements.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from __future__ import annotations
22

3+
from dataclasses import dataclass
34
from typing import Any
45

56
import pytest
6-
from typing_extensions import dataclass_transform
77

88
import nexusrpc
99
from nexusrpc._util import get_service_definition
@@ -15,12 +15,8 @@
1515
from nexusrpc.handler._decorators import operation_handler
1616

1717

18-
@dataclass_transform()
19-
class _TestCase:
20-
pass
21-
22-
23-
class _DecoratorValidationTestCase(_TestCase):
18+
@dataclass()
19+
class _DecoratorValidationTestCase:
2420
UserService: type[Any]
2521
UserServiceHandler: type[Any]
2622
expected_error_message_pattern: str
@@ -71,7 +67,8 @@ def test_decorator_validates_definition_compliance(
7167
service_handler(service=test_case.UserService)(test_case.UserServiceHandler)
7268

7369

74-
class _ServiceHandlerInheritanceTestCase(_TestCase):
70+
@dataclass()
71+
class _ServiceHandlerInheritanceTestCase:
7572
UserServiceHandler: type[Any]
7673
expected_operations: set[str]
7774

@@ -134,7 +131,8 @@ def test_service_implementation_inheritance(
134131
)
135132

136133

137-
class _ServiceDefinitionInheritanceTestCase(_TestCase):
134+
@dataclass()
135+
class _ServiceDefinitionInheritanceTestCase:
138136
UserService: type[Any]
139137
expected_ops: set[str]
140138

tests/handler/test_service_handler_decorator_results_in_correctly_functioning_operation_factories.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
Test that operation decorators result in operation factories that return the correct result.
33
"""
44

5+
from dataclasses import dataclass
56
from typing import Any, Union, cast
67

78
import pytest
8-
from typing_extensions import dataclass_transform
99

1010
import nexusrpc
1111
from nexusrpc import InputT, OutputT
@@ -26,12 +26,8 @@
2626
from tests.helpers import TestOperationTaskCancellation
2727

2828

29-
@dataclass_transform()
30-
class _BaseTestCase:
31-
pass
32-
33-
34-
class _TestCase(_BaseTestCase):
29+
@dataclass()
30+
class _TestCase:
3531
Service: type[Any]
3632
expected_operation_factories: dict[str, Any]
3733

tests/handler/test_service_handler_decorator_selects_correct_service_name.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
from dataclasses import dataclass
12
from typing import Optional
23

34
import pytest
4-
from typing_extensions import dataclass_transform
55

66
import nexusrpc
77
from nexusrpc._util import get_service_definition
@@ -18,12 +18,8 @@ class ServiceInterfaceWithNameOverride:
1818
pass
1919

2020

21-
@dataclass_transform()
22-
class _BaseTestCase:
23-
pass
24-
25-
26-
class _NameOverrideTestCase(_BaseTestCase):
21+
@dataclass()
22+
class _NameOverrideTestCase:
2723
ServiceImpl: type
2824
expected_name: str
2925
expected_error: Optional[type[Exception]] = None

0 commit comments

Comments
 (0)