diff --git a/README.md b/README.md index f39488d..fe3ae4a 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ A high-performance, type-safe Mediator pattern implementation for Python 3.13+, ## Features - **Result-Driven Development**: Built-in support for `flow-res` Result types, making error handling explicit and type-safe. -- **Auto-Registration**: Handlers are automatically registered using Python 3.13's `__init_subclass__` and generic introspection. +- **Auto-Registration**: Each `Mediator` discovers concrete handlers using Python 3.13's generic introspection. - **Dependency Injection**: Seamless integration with the `injector` library for robust dependency management. - **Native Async Support**: Designed from the ground up for `asyncio` with `AwaitableResult` support for elegant method chaining. -- **Strict Type Safety**: Fully compatible with `pyright` and `mypy` using modern Python 3.13 type parameters. +- **Strict Type Safety**: Public type contracts are checked by `pyright`/`mypy`, with result-type validation at handler registration. ## Installation @@ -35,7 +35,7 @@ class GetUserRequest(Request[Result[str, Exception]]): ### 2. Implement Handler -Handlers are automatically registered when defined. Use `@override` to ensure correct implementation. +Concrete handlers are discovered by each `Mediator` instance. Use `@override` to ensure correct implementation. ```python from typing import override @@ -49,7 +49,7 @@ class GetUserHandler(RequestHandler[GetUserRequest, Result[str, Exception]]): return Ok(f"User {request.user_id}") ``` -### 3. Initialize and Send +### 3. Create a Mediator and Send ```python import asyncio @@ -57,12 +57,12 @@ from injector import Injector from flow_med import Mediator async def main(): - # Initialize with an Injector - Mediator.initialize(Injector()) + # Each application (or test) owns its Mediator and Injector. + mediator = Mediator(Injector()) # Send request and chain results using flow-res result = await ( - Mediator.send_async(GetUserRequest(user_id=1)) + mediator.send_async(GetUserRequest(user_id=1)) .map(lambda name: f"Hello, {name}!") .unwrap() ) diff --git a/examples/basic_usage.py b/examples/basic_usage.py index a72307a..35ef242 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -14,7 +14,7 @@ def __init__(self, user_id: int): # 2. Implement Handler -# Handlers are automatically registered when defined. +# Concrete handlers are discovered by each Mediator instance. class GetUserHandler(RequestHandler[GetUserRequest, Result[str, Exception]]): @override async def handle(self, request: GetUserRequest) -> Result[str, Exception]: @@ -23,9 +23,9 @@ async def handle(self, request: GetUserRequest) -> Result[str, Exception]: async def main(): - # 3. Initialize with an Injector - # In a real app, you would configure your modules here. - Mediator.initialize(Injector()) + # 3. Create a Mediator with an Injector. + # In a real app, configure the Injector with your modules here. + mediator = Mediator(Injector()) print("--- Basic Usage Example ---") @@ -33,7 +33,7 @@ async def main(): # Mediator.send_async returns an AwaitableResult, # allowing you to chain operations before awaiting. result = await ( - Mediator.send_async(GetUserRequest(user_id=42)) + mediator.send_async(GetUserRequest(user_id=42)) .map(lambda name: f"Hello, {name}!") .unwrap() ) diff --git a/examples/di_usage.py b/examples/di_usage.py index 204a2ff..c3453d8 100644 --- a/examples/di_usage.py +++ b/examples/di_usage.py @@ -28,7 +28,7 @@ def __init__(self, user_id: int): # 4. Implement Handler with DI -# RequestHandler is automatically registered. +# RequestHandler implementations are discovered by each Mediator instance. class GetUserHandler(RequestHandler[GetUserRequest, Result[str, Exception]]): # UserRepository is injected via the constructor @inject @@ -42,15 +42,15 @@ async def handle(self, request: GetUserRequest) -> Result[str, Exception]: async def main(): - # 5. Initialize with an Injector containing our Module + # 5. Create a Mediator with an Injector containing our Module injector = Injector([UserModule()]) - Mediator.initialize(injector) + mediator = Mediator(injector) print("--- Dependency Injection Example ---") # 6. Send request result = await ( - Mediator.send_async(GetUserRequest(user_id=123)) + mediator.send_async(GetUserRequest(user_id=123)) .map(lambda name: f"Retrieved: {name}") .unwrap() ) diff --git a/pyproject.toml b/pyproject.toml index e115205..d046381 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ source = "vcs" [tool.hatch.build.targets.wheel] packages = ["src/flow_med"] +force-include = { "src/flow_med/py.typed" = "flow_med/py.typed" } [project.urls] Homepage = "https://github.com/aiagate/flow-med" @@ -76,3 +77,9 @@ addopts = [ "--cov-report=html", ] asyncio_default_fixture_loop_scope = "function" + +[tool.pyright] +include = ["src", "tests", "examples"] +exclude = ["tests/typecheck"] +venvPath = "." +venv = ".venv" diff --git a/src/flow_med/__init__.py b/src/flow_med/__init__.py index 4495e09..e946b3e 100644 --- a/src/flow_med/__init__.py +++ b/src/flow_med/__init__.py @@ -1,3 +1,19 @@ -from .mediator import HandlerNotFoundError, Mediator, Request, RequestHandler +from .mediator import ( + DuplicateHandlerError, + HandlerNotFoundError, + HandlerRegistrationError, + InvalidHandlerError, + Mediator, + Request, + RequestHandler, +) -__all__ = ["HandlerNotFoundError", "Mediator", "Request", "RequestHandler"] +__all__ = [ + "DuplicateHandlerError", + "HandlerNotFoundError", + "HandlerRegistrationError", + "InvalidHandlerError", + "Mediator", + "Request", + "RequestHandler", +] diff --git a/src/flow_med/mediator.py b/src/flow_med/mediator.py index e4a0805..52a8954 100644 --- a/src/flow_med/mediator.py +++ b/src/flow_med/mediator.py @@ -1,6 +1,9 @@ -import logging +"""Type-safe request/response mediation with dependency injection.""" + from abc import ABC, abstractmethod -from typing import Any, ClassVar +import inspect +import logging +from typing import Any, TypeVar, cast, get_args, get_origin from flow_res import AwaitableResult, Result from injector import Injector @@ -9,7 +12,10 @@ class Request[R]: - """Base class for all requests.""" + """Base class for all requests. + + ``R`` is the exact result type returned by the request's handler. + """ pass @@ -17,7 +23,9 @@ class Request[R]: class RequestHandler[T: Request[Any], R](ABC): """Base class for request handlers. - Subclasses are automatically registered with the Mediator. + Concrete subclasses are discovered by each :class:`Mediator` instance. + Discovery is intentionally deferred to the mediator so no process-wide + mutable registry is needed. """ @abstractmethod @@ -26,86 +34,299 @@ async def handle(self, request: T) -> R: pass def __init_subclass__(cls, **kwargs: Any) -> None: - """Automatically register the handler with the Mediator.""" - super().__init_subclass__(**kwargs) + """Keep subclass creation side-effect free. - # Do not register abstract classes (classes without implementation) - if getattr(cls, "__abstractmethods__", None): - return + ``ABCMeta`` has not finished calculating ``__abstractmethods__`` while + this hook runs. Mediator discovery therefore performs the abstract + class check after class creation instead. + """ - # Get RequestType from RequestHandler[RequestType, ReturnType] - for base in getattr(cls, "__orig_bases__", []): - if getattr(base, "__origin__", None) is RequestHandler: - request_type = base.__args__[0] - logger.debug("Registering handler: %s -> %s", request_type, cls) - Mediator.register(request_type, cls) - break + super().__init_subclass__(**kwargs) class Mediator: - """Mediator for sending requests to their respective handlers.""" + """Send requests to handlers resolved from an instance-owned registry.""" - _request_handlers: ClassVar[dict[type[Any], type[Any]]] = {} - _injector: ClassVar[Injector | None] = None + def __init__(self, injector: Injector) -> None: + self._injector = injector + self._request_handlers: dict[ + type[Request[Any]], type[RequestHandler[Any, Any]] + ] = {} + self._manual_requests: set[type[Request[Any]]] = set() - @classmethod - def initialize(cls, injector: Injector) -> None: - """Initialize mediator with injector. - - This method should be called once at application startup. - """ - cls._injector = injector - - @classmethod def send_async[T, E: Exception]( - cls, request: Request[Result[T, E]] + self, request: Request[Result[T, E]] ) -> AwaitableResult[T, E]: - """ - Send a request to its handler. + """Send a request and return an awaitable result for method chaining.""" - Returns AwaitableResult for method chaining. - """ + injector = self._injector async def execute() -> Result[T, E]: logger.debug("Mediator.send_async: %s", request) - if cls._injector is None: - raise RuntimeError( - "Mediator not initialized. Call Mediator.initialize() first." - ) - - handler_type = cls._request_handlers.get(type(request)) - if not handler_type: + self._discover_handlers(type(request)) + handler_type = self._request_handlers.get(type(request)) + if handler_type is None: raise HandlerNotFoundError(request) - handler = cls._injector.get(handler_type) - return await handler.handle(request) # type: ignore[no-any-return] + handler = injector.get(handler_type) + result = await handler.handle(request) + return cast(Result[T, E], result) return AwaitableResult(execute()) - @classmethod - def register(cls, request_type: type[Any], handler_type: type[Any]) -> None: - """Manually register a handler for a request type.""" + def register[ + T: Request[Any], + R, + ](self, request_type: type[T], handler_type: type[RequestHandler[T, R]]) -> None: + """Register a handler explicitly. + + Registration is strict: an existing mapping must be replaced through + :meth:`replace`, and the handler's declared request/result contract is + checked before it is stored. + """ + + self._validate_request_type(request_type) + self._validate_handler_type(request_type, handler_type) + if request_type in self._request_handlers: + raise DuplicateHandlerError( + request_type, self._request_handlers[request_type] + ) + logger.debug("Mediator.register: %s -> %s", request_type, handler_type) - cls._request_handlers[request_type] = handler_type + self._request_handlers[request_type] = handler_type + self._manual_requests.add(request_type) + + def replace[ + T: Request[Any], + R, + ](self, request_type: type[T], handler_type: type[RequestHandler[T, R]]) -> None: + """Explicitly replace the handler registered for ``request_type``.""" + + self._validate_request_type(request_type) + self._validate_handler_type(request_type, handler_type) + if request_type not in self._request_handlers: + raise HandlerNotFoundError(request_type) + + logger.debug("Mediator.replace: %s -> %s", request_type, handler_type) + self._request_handlers[request_type] = handler_type + self._manual_requests.add(request_type) + + def _discover_handlers(self, request_type_to_find: type[Request[Any]]) -> None: + """Discover concrete handlers for one request type.""" + + for handler_type in _iter_handler_types(RequestHandler): + if inspect.isabstract(handler_type): + continue + + contract = _handler_contract(handler_type) + if contract is None: + continue + declared_request_type, handler_result = contract + if declared_request_type is not request_type_to_find: + continue + if declared_request_type in self._manual_requests: + continue + + self._validate_result_type( + declared_request_type, handler_type, handler_result + ) + existing = self._request_handlers.get(declared_request_type) + if existing is None: + logger.debug( + "Auto-registering handler: %s -> %s", + declared_request_type, + handler_type, + ) + self._request_handlers[declared_request_type] = handler_type + elif existing is not handler_type: + raise DuplicateHandlerError( + declared_request_type, existing, handler_type + ) + + def _validate_request_type(self, request_type: type[Any]) -> None: + if not isinstance(request_type, type) or not issubclass(request_type, Request): + raise InvalidHandlerError( + request_type, "request_type must be a Request subclass" + ) + + def _validate_handler_type( + self, + request_type: type[Request[Any]], + handler_type: type[RequestHandler[Any, Any]], + ) -> None: + if not isinstance(handler_type, type) or not issubclass( + handler_type, RequestHandler + ): + raise InvalidHandlerError( + handler_type, "handler_type must be a RequestHandler subclass" + ) + if inspect.isabstract(handler_type): + raise InvalidHandlerError( + handler_type, "abstract handlers cannot be registered" + ) + + contract = _handler_contract(handler_type) + if contract is None or contract[0] is not request_type: + raise InvalidHandlerError( + handler_type, + f"handler is declared for {contract[0] if contract else 'an unknown request'}", + ) + self._validate_result_type(request_type, handler_type, contract[1]) + + def _validate_result_type( + self, + request_type: type[Request[Any]], + handler_type: type[RequestHandler[Any, Any]], + handler_result: Any, + ) -> None: + request_result = _request_result_type(request_type) + if ( + request_result is not None + and request_result is not Any + and handler_result is not Any + and request_result != handler_result + ): + raise InvalidHandlerError( + handler_type, + f"result type {handler_result!r} does not match request result {request_result!r}", + ) + + +def _iter_handler_types(base: type[Any]) -> list[type[RequestHandler[Any, Any]]]: + """Return all subclasses recursively, without relying on mutable globals.""" + + discovered: list[type[RequestHandler[Any, Any]]] = [] + seen: set[type[Any]] = set() + pending = list(base.__subclasses__()) + while pending: + candidate = pending.pop() + if candidate in seen: # pragma: no cover - defensive for diamond inheritance + continue + seen.add(candidate) + discovered.append(cast(type[RequestHandler[Any, Any]], candidate)) + pending.extend(candidate.__subclasses__()) + return discovered + + +def _handler_contract( + handler_type: type[Any], +) -> tuple[type[Request[Any]], Any] | None: + contract = _find_generic_base(handler_type, RequestHandler) + if contract is None or len(contract) != 2: + return None + request_type, result_type = contract + if not isinstance(request_type, type) or not issubclass(request_type, Request): + return None + if _contains_typevar(result_type): + return None + return cast(type[Request[Any]], request_type), result_type + + +def _request_result_type(request_type: type[Any]) -> Any | None: + contract = _find_generic_base(request_type, Request) + return None if contract is None else contract[0] + + +def _find_generic_base(cls: type[Any], target: type[Any]) -> tuple[Any, ...] | None: + """Resolve a target generic base while substituting intermediate TypeVars.""" + + def visit( + current: type[Any], substitutions: dict[TypeVar, Any] + ) -> tuple[Any, ...] | None: + for base in getattr(current, "__orig_bases__", ()): + origin = get_origin(base) or base + args = get_args(base) + parameters = getattr(origin, "__parameters__", ()) + local_substitutions = dict(substitutions) + for parameter, argument in zip(parameters, args, strict=False): + local_substitutions[parameter] = _resolve_type(argument, substitutions) + + resolved_args = tuple( + _resolve_type(argument, substitutions) for argument in args + ) + if origin is target: + return resolved_args + if isinstance(origin, type) and issubclass(origin, target): + result = visit(origin, local_substitutions) + if result is not None: + return result + return None + + return visit(cls, {}) + + +def _resolve_type(value: Any, substitutions: dict[TypeVar, Any]) -> Any: + while isinstance(value, TypeVar) and value in substitutions: + replacement = substitutions[value] + if replacement is value: # pragma: no cover - guarded malformed environment + break + value = replacement + args = get_args(value) + if not args: + return value + + resolved_args = tuple(_resolve_type(argument, substitutions) for argument in args) + if resolved_args == args: + return value + + origin = get_origin(value) + if origin is None: + return value + try: + return origin[resolved_args[0] if len(resolved_args) == 1 else resolved_args] + except (AttributeError, TypeError): + return value + + +def _contains_typevar(value: Any) -> bool: + if isinstance(value, TypeVar): + return True + return any(_contains_typevar(argument) for argument in get_args(value)) class MediatorError(Exception): """Base error for Mediator.""" - pass - class HandlerNotFoundError(MediatorError): """Raised when no handler is found for a request.""" def __init__(self, target: Any) -> None: + request_type = target if isinstance(target, type) else type(target) + super().__init__(f"Handler not found for request type: {request_type}") + + +class HandlerRegistrationError(MediatorError): + """Raised when a handler registration violates the mediator contract.""" + + +class InvalidHandlerError(HandlerRegistrationError): + """Raised for an invalid, abstract, or type-inconsistent handler.""" + + def __init__(self, handler_type: Any, reason: str) -> None: + super().__init__(f"Invalid handler {handler_type!r}: {reason}") + + +class DuplicateHandlerError(HandlerRegistrationError): + """Raised when more than one handler claims the same request type.""" + + def __init__( + self, + request_type: type[Any], + existing: type[Any], + duplicate: type[Any] | None = None, + ) -> None: + duplicate_text = f" and {duplicate!r}" if duplicate is not None else "" super().__init__( - f"Handler not found for request type: {type(target)}", + f"Multiple handlers registered for {request_type!r}: {existing!r}{duplicate_text}" ) __all__ = [ + "DuplicateHandlerError", "HandlerNotFoundError", + "HandlerRegistrationError", + "InvalidHandlerError", "Mediator", "Request", "RequestHandler", diff --git a/src/flow_med/py.typed b/src/flow_med/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..875d768 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Shared fixtures for mediator tests.""" + +import pytest +from injector import Injector + +from flow_med import Mediator + + +@pytest.fixture +def mediator() -> Mediator: + """Return a fresh mediator so tests do not share registry or DI state.""" + return Mediator(Injector()) diff --git a/tests/test_mediator.py b/tests/test_mediator.py index 95aa853..98807ac 100644 --- a/tests/test_mediator.py +++ b/tests/test_mediator.py @@ -1,15 +1,21 @@ -"""Tests for the Mediator.""" +"""Behavioral regression tests for the instance-based Mediator API.""" -from typing import override +from abc import abstractmethod +from typing import Any, Generic, TypeVar, cast, override import pytest from flow_res import Ok, Result -from injector import Injector +from injector import Injector, Module, inject -from flow_med import HandlerNotFoundError, Mediator, Request, RequestHandler - -# Initialize Mediator for tests -Mediator.initialize(Injector()) +from flow_med import ( + DuplicateHandlerError, + HandlerNotFoundError, + InvalidHandlerError, + Mediator, + Request, + RequestHandler, +) +from flow_med.mediator import _find_generic_base, _resolve_type class MyQuery(Request[Result[str, Exception]]): @@ -27,29 +33,313 @@ class AnotherQuery(Request[Result[int, Exception]]): @pytest.mark.anyio -async def test_mediator_send_registered_request() -> None: - """Test that a request with an auto-registered handler can be sent.""" - # The MyQueryHandler should be auto-registered via __init_subclass__ - result = await Mediator.send_async(MyQuery()).unwrap() +async def test_mediator_sends_to_an_auto_registered_handler( + mediator: Mediator, +) -> None: + result = await mediator.send_async(MyQuery()).unwrap() + assert result == "Handled" @pytest.mark.anyio -async def test_mediator_send_unregistered_raises_error() -> None: - """Test that sending an unregistered request raises HandlerNotFoundError.""" +async def test_mediator_reports_an_unregistered_request( + mediator: Mediator, +) -> None: with pytest.raises( HandlerNotFoundError, match="Handler not found for request type" ): - await Mediator.send_async(AnotherQuery()) + await mediator.send_async(AnotherQuery()) + + +class AbstractQuery(Request[Result[str, Exception]]): + pass + + +class AbstractQueryHandler(RequestHandler[AbstractQuery, Result[str, Exception]]): + @abstractmethod + def required_dependency(self) -> str: + """Keep this handler abstract even though handle has a body.""" + + @override + async def handle(self, request: AbstractQuery) -> Result[str, Exception]: + return Ok(self.required_dependency()) + + +@pytest.mark.anyio +async def test_abstract_handler_is_not_auto_registered( + mediator: Mediator, +) -> None: + with pytest.raises(HandlerNotFoundError): + await mediator.send_async(AbstractQuery()) + + +RequestT = TypeVar("RequestT", bound=Request[Any]) + + +class GenericHandler( + RequestHandler[RequestT, Result[str, Exception]], Generic[RequestT] +): + """Intermediate generic handler; it must not be registered by its TypeVar.""" + + @abstractmethod + async def handle(self, request: RequestT) -> Result[str, Exception]: + pass + + +class GenericQuery(Request[Result[str, Exception]]): + pass + + +class ConcreteGenericHandler(GenericHandler[GenericQuery]): + @override + async def handle(self, request: GenericQuery) -> Result[str, Exception]: + return Ok("Concrete generic handler") + + +@pytest.mark.anyio +async def test_concrete_handler_through_generic_base_is_auto_registered( + mediator: Mediator, +) -> None: + result = await mediator.send_async(GenericQuery()).unwrap() + + assert result == "Concrete generic handler" + + +@pytest.mark.anyio +async def test_duplicate_manual_registration_is_rejected( + mediator: Mediator, +) -> None: + """A second registration must not silently change the selected handler.""" + + class DuplicateQuery(Request[Result[str, Exception]]): + pass + + class DuplicateQueryHandler(RequestHandler[DuplicateQuery, Result[str, Exception]]): + @override + async def handle(self, request: DuplicateQuery) -> Result[str, Exception]: + return Ok("first") + + mediator.register(DuplicateQuery, DuplicateQueryHandler) + + with pytest.raises(DuplicateHandlerError, match="Multiple handlers"): + mediator.register(DuplicateQuery, DuplicateQueryHandler) + + assert await mediator.send_async(DuplicateQuery()).unwrap() == "first" + + +def test_registration_validation_and_explicit_replacement(mediator: Mediator) -> None: + class DuplicateQuery(Request[Result[str, Exception]]): + pass + + class DuplicateQueryHandler(RequestHandler[DuplicateQuery, Result[str, Exception]]): + @override + async def handle(self, request: DuplicateQuery) -> Result[str, Exception]: + return Ok("first") + + class OtherQuery(Request[Result[str, Exception]]): + pass + + class OtherHandler(RequestHandler[OtherQuery, Result[str, Exception]]): + @override + async def handle(self, request: OtherQuery) -> Result[str, Exception]: + return Ok("other") + + with pytest.raises(InvalidHandlerError, match="request_type"): + mediator.register(cast(Any, object), DuplicateQueryHandler) + with pytest.raises(InvalidHandlerError, match="RequestHandler"): + mediator.register(DuplicateQuery, cast(Any, object)) + with pytest.raises(InvalidHandlerError, match="declared"): + mediator.register(DuplicateQuery, cast(Any, OtherHandler)) + with pytest.raises(InvalidHandlerError, match="abstract"): + mediator.register(AbstractQuery, AbstractQueryHandler) + with pytest.raises(HandlerNotFoundError): + mediator.replace(DuplicateQuery, DuplicateQueryHandler) + + mediator.register(DuplicateQuery, DuplicateQueryHandler) + + class ReplacementHandler(RequestHandler[DuplicateQuery, Result[str, Exception]]): + @override + async def handle(self, request: DuplicateQuery) -> Result[str, Exception]: + return Ok("replacement") + + mediator.replace(DuplicateQuery, ReplacementHandler) + + +@pytest.mark.anyio +async def test_duplicate_auto_handlers_are_rejected(mediator: Mediator) -> None: + class AutoDuplicateQuery(Request[Result[str, Exception]]): + pass + + class FirstHandler(RequestHandler[AutoDuplicateQuery, Result[str, Exception]]): + @override + async def handle(self, request: AutoDuplicateQuery) -> Result[str, Exception]: + return Ok("first") + + class SecondHandler(RequestHandler[AutoDuplicateQuery, Result[str, Exception]]): + @override + async def handle(self, request: AutoDuplicateQuery) -> Result[str, Exception]: + return Ok("second") + + class MalformedHandler(RequestHandler): + async def handle(self, request: Any) -> Any: + return None + + class InvalidRequestHandler(RequestHandler[Any, Any]): + async def handle(self, request: Any) -> Any: + return None + + ResultT = TypeVar("ResultT") + + class GenericResultHandler( + RequestHandler[AutoDuplicateQuery, ResultT], Generic[ResultT] + ): + async def handle(self, request: AutoDuplicateQuery) -> ResultT: + raise NotImplementedError + + with pytest.raises(DuplicateHandlerError, match="Multiple handlers"): + await mediator.send_async(AutoDuplicateQuery()) + + +@pytest.mark.anyio +async def test_inconsistent_result_type_is_rejected_on_manual_registration( + mediator: Mediator, +) -> None: + class NumberQuery(Request[Result[int, Exception]]): + pass + + class WrongResultHandler(RequestHandler[NumberQuery, Result[str, Exception]]): + @override + async def handle(self, request: NumberQuery) -> Result[str, Exception]: + return Ok("wrong") + + with pytest.raises(InvalidHandlerError, match="result type"): + mediator.register(NumberQuery, WrongResultHandler) + + +class Dependency: + def __init__(self, value: str) -> None: + self.value = value + + +class DependencyModule(Module): + def __init__(self, value: str) -> None: + self.value = value + + def configure(self, binder: Any) -> None: + binder.bind(Dependency, to=Dependency(self.value)) + + +class LifecycleQuery(Request[Result[str, Exception]]): + pass + + +class LifecycleHandler(RequestHandler[LifecycleQuery, Result[str, Exception]]): + @inject + def __init__(self, dependency: Dependency) -> None: + self.dependency = dependency + + @override + async def handle(self, request: LifecycleQuery) -> Result[str, Exception]: + return Ok(self.dependency.value) + + +def test_mediators_have_independent_injectors_and_registries() -> None: + first = Mediator(Injector([DependencyModule("first")])) + second = Mediator(Injector([DependencyModule("second")])) + + assert first is not second @pytest.mark.anyio -async def test_mediator_not_initialized_raises_error() -> None: - """Test that calling send_async before initialization raises RuntimeError.""" - original_injector = Mediator._injector - Mediator._injector = None - try: - with pytest.raises(RuntimeError, match="Mediator not initialized"): - await Mediator.send_async(MyQuery()) - finally: - Mediator._injector = original_injector +async def test_pending_send_uses_its_mediator_instance( + mediator: Mediator, +) -> None: + first = Mediator(Injector([DependencyModule("first")])) + second = Mediator(Injector([DependencyModule("second")])) + + pending = first.send_async(LifecycleQuery()) + del second + + assert await pending.unwrap() == "first" + + +@pytest.mark.anyio +async def test_manual_registration_and_explicit_replace( + mediator: Mediator, +) -> None: + class LateQuery(Request[Result[str, Exception]]): + pass + + class FirstHandler(RequestHandler[LateQuery, Result[str, Exception]]): + @override + async def handle(self, request: LateQuery) -> Result[str, Exception]: + return Ok("first") + + class SecondHandler(RequestHandler[LateQuery, Result[str, Exception]]): + @override + async def handle(self, request: LateQuery) -> Result[str, Exception]: + return Ok("second") + + mediator.register(LateQuery, FirstHandler) + assert await mediator.send_async(LateQuery()).unwrap() == "first" + + mediator.replace(LateQuery, SecondHandler) + assert await mediator.send_async(LateQuery()).unwrap() == "second" + + +def test_invalid_manual_registration_is_rejected(mediator: Mediator) -> None: + with pytest.raises(InvalidHandlerError, match="Request subclass"): + mediator.register(cast(Any, object), cast(Any, object)) + + with pytest.raises(InvalidHandlerError, match="RequestHandler subclass"): + mediator.register(AnotherQuery, cast(Any, object)) + + class AbstractManualHandler(RequestHandler[AnotherQuery, Result[int, Exception]]): + @abstractmethod + def dependency(self) -> None: + pass + + @override + async def handle(self, request: AnotherQuery) -> Result[int, Exception]: + return Ok(1) + + with pytest.raises(InvalidHandlerError, match="abstract"): + mediator.register(AnotherQuery, AbstractManualHandler) + + class OtherQuery(Request[Result[int, Exception]]): + pass + + class OtherHandler(RequestHandler[OtherQuery, Result[int, Exception]]): + @override + async def handle(self, request: OtherQuery) -> Result[int, Exception]: + return Ok(1) + + with pytest.raises(InvalidHandlerError, match="declared for"): + mediator.register(AnotherQuery, cast(Any, OtherHandler)) + + +@pytest.mark.anyio +async def test_handlers_without_concrete_generic_contract_are_ignored( + mediator: Mediator, +) -> None: + class IncompleteHandler(RequestHandler): + @override + async def handle(self, request: Any) -> Any: + return None + + HandlerResultT = TypeVar("HandlerResultT") + + class TypeVarResultHandler(RequestHandler[AnotherQuery, HandlerResultT]): + @override + async def handle(self, request: AnotherQuery) -> HandlerResultT: + raise NotImplementedError + + with pytest.raises(HandlerNotFoundError): + await mediator.send_async(AnotherQuery()) + + +def test_generic_introspection_handles_non_matching_and_nested_types() -> None: + assert _find_generic_base(object, RequestHandler) is None + + NestedTypeT = cast(Any, TypeVar("NestedTypeT")) + assert _resolve_type(list[NestedTypeT], {NestedTypeT: str}) == list[str] diff --git a/tests/test_typing_and_distribution.py b/tests/test_typing_and_distribution.py new file mode 100644 index 0000000..125a2e5 --- /dev/null +++ b/tests/test_typing_and_distribution.py @@ -0,0 +1,125 @@ +"""Regression tests for static typing and distribution metadata.""" + +import json +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parents[1] + + +def _pyright_command() -> list[str]: + local_executable = ROOT / ".venv" / "Scripts" / "pyright.exe" + executable = ( + str(local_executable) if local_executable.exists() else shutil.which("pyright") + ) + if executable is None: + pytest.skip("pyright is required for typing regression tests") + return [str(executable)] + + +def _run_pyright(path: Path, tmp_path: Path, *, extra_paths: list[Path]) -> dict: + checked_path = tmp_path / path.name + tmp_path.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, checked_path) + config = tmp_path / "pyrightconfig.json" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text( + json.dumps( + { + "include": [checked_path.name], + "extraPaths": [ + str(item) + for item in [ + *extra_paths, + ROOT / ".venv" / "Lib" / "site-packages", + ] + ], + "typeCheckingMode": "strict", + } + ), + encoding="utf-8", + ) + completed = subprocess.run( + [*_pyright_command(), "--project", str(config), "--outputjson"], + cwd=path.parent, + capture_output=True, + text=True, + check=False, + ) + return json.loads(completed.stdout) + + +@pytest.mark.parametrize( + "fixture_name", + ["invalid_result.py", "invalid_registration.py"], +) +def test_invalid_public_typing_contract_is_rejected( + fixture_name: str, tmp_path: Path +) -> None: + report = _run_pyright( + ROOT / "tests" / "typecheck" / fixture_name, + tmp_path, + extra_paths=[ROOT / "src"], + ) + + assert report["summary"]["errorCount"] > 0, report + + +def test_wheel_contains_py_typed_and_supports_consumer_typecheck( + tmp_path: Path, +) -> None: + build_dir = tmp_path / "dist" + build_dir.mkdir() + build = subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(build_dir)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + assert build.returncode == 0, build.stdout + build.stderr + + wheels = list(build_dir.glob("*.whl")) + assert len(wheels) == 1 + + package_dir = tmp_path / "site-packages" + package_dir.mkdir() + with zipfile.ZipFile(wheels[0]) as wheel: + names = set(wheel.namelist()) + assert "flow_med/py.typed" in names + wheel.extractall(package_dir) + + consumer = tmp_path / "consumer.py" + consumer.write_text( + """\ +from flow_res import Ok, Result +from flow_med import Mediator, Request, RequestHandler +from injector import Injector + + +class Query(Request[Result[int, Exception]]): + pass + + +class Handler(RequestHandler[Query, Result[int, Exception]]): + async def handle(self, request: Query) -> Result[int, Exception]: + return Ok(1) + + +mediator = Mediator(Injector()) +mediator.send_async(Query()) +""", + encoding="utf-8", + ) + report = _run_pyright( + consumer, + tmp_path / "consumer-config", + extra_paths=[package_dir, ROOT / ".venv" / "Lib" / "site-packages"], + ) + + assert report["summary"]["errorCount"] == 0 diff --git a/tests/typecheck/invalid_registration.py b/tests/typecheck/invalid_registration.py new file mode 100644 index 0000000..3f6245f --- /dev/null +++ b/tests/typecheck/invalid_registration.py @@ -0,0 +1,13 @@ +"""This fixture must be rejected: object is not a request handler type.""" + +from flow_res import Result + +from flow_med import Mediator, Request + + +class NumberQuery(Request[Result[int, Exception]]): + pass + + +mediator = Mediator() +mediator.register(NumberQuery, object) diff --git a/tests/typecheck/invalid_result.py b/tests/typecheck/invalid_result.py new file mode 100644 index 0000000..8de8ef6 --- /dev/null +++ b/tests/typecheck/invalid_result.py @@ -0,0 +1,17 @@ +"""This fixture must be rejected: handler result does not match its request.""" + +from typing import override + +from flow_res import Result + +from flow_med import Request, RequestHandler + + +class NumberQuery(Request[Result[int, Exception]]): + pass + + +class WrongResultHandler(RequestHandler[NumberQuery, Result[int, Exception]]): + @override + async def handle(self, request: NumberQuery) -> Result[str, Exception]: + raise NotImplementedError