diff --git a/CHANGELOG.md b/CHANGELOG.md index 08dab9a..028e4a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,18 @@ # Change Log +## [0.10.0] - 2024-03-17 + +### Added + +- Add async support in `Application`. New methods: `call_async`, `execute_async`, `publish_async` +- Add async support in `TransactionContext`. New methods: `call_async`, `execute_async`, `publish_async` ## [0.9.0] - 2024-02-28 +### Added + +- Add Sphinx docs + ### Changed - Rename `Task` to `Command` @@ -12,4 +22,4 @@ ## [0.8.0] - 2024-01-08 -No history. \ No newline at end of file +Early release. \ No newline at end of file diff --git a/Makefile b/Makefile index 982aca4..30552cb 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,10 @@ format: pre-commit run --all-files + +test: + pytest + mypy lato + pytest --doctest-modules lato docs_autobuild: sphinx-autobuild --watch lato -E docs docs/_build/html diff --git a/README.md b/README.md index c64744e..7803b57 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Based on dependency injection and Python 3.6+ type hints. - **Minimalistic**: Intuitive and lean API for rapid development without the bloat. +- **Async Support**: Concurrency and async / await is supported. + ## Installation diff --git a/examples/async_example/example1.py b/examples/async_example/example1.py new file mode 100644 index 0000000..9924877 --- /dev/null +++ b/examples/async_example/example1.py @@ -0,0 +1,22 @@ +import asyncio + +from lato import Application, TransactionContext + + +async def add_async(a, b): + await asyncio.sleep(1) + return a + b + + +if __name__ == "__main__": + with TransactionContext() as ctx: + result = ctx.call(add_async, 1, 2) + print(result) + + result = asyncio.run(result) + + print("got result from asyncio.run", result) + + app = Application("async") + result = app.call(add_async, 1, 2) + print(result) diff --git a/examples/async_example/example2.py b/examples/async_example/example2.py new file mode 100644 index 0000000..ceedb4e --- /dev/null +++ b/examples/async_example/example2.py @@ -0,0 +1,21 @@ +import asyncio + +from lato import Application, Command + + +class MultiplyCommand(Command): + a: int + b: int + + +app = Application("async") + + +@app.handler(MultiplyCommand) +async def multiply_async(command: MultiplyCommand): + await asyncio.sleep(1) + return command.a * command.b + + +coroutine = app.execute(MultiplyCommand(a=10, b=20)) +print("execution result", coroutine) diff --git a/examples/async_example/toy.py b/examples/async_example/toy.py new file mode 100644 index 0000000..4bff33d --- /dev/null +++ b/examples/async_example/toy.py @@ -0,0 +1,57 @@ +import asyncio +import logging + +from lato import Application, TransactionContext + +logging.basicConfig(level=logging.DEBUG) +root_logger = logging.getLogger("toy") + +app = Application() + + +class Counter: + def __init__(self): + self.value = 0 + + def next(self): + self.value += 1 + return self.value + + +counter = Counter() + + +@app.on_enter_transaction_context +async def on_enter_transaction_context(ctx: TransactionContext): + correlation_id = str(counter.next()) + logger = root_logger.getChild(correlation_id) + ctx.set_dependencies(logger=logger) + logger.info("Connecting to database") + await asyncio.sleep(0.001) + logger.info("Connected") + + +@app.on_exit_transaction_context +async def on_exit_transaction_context(ctx: TransactionContext, exception=None): + logger = ctx["logger"] + logger.info("Disconnecting from database") + await asyncio.sleep(0.001) + logger.info("Disconnected from database") + + +@app.handler("foo") +async def handle_foo(x, logger): + logger.info(f"Starting foo, x={x}") + await asyncio.sleep(0.001) + logger.info("Finished foo") + + +async def main() -> None: + await asyncio.gather( + app.call_async("foo", x=1), + app.call_async("foo", x=2), + app.call_async("foo", x=3), + ) + + +asyncio.run(main()) diff --git a/lato/application.py b/lato/application.py index 1fae825..0d4fcd9 100644 --- a/lato/application.py +++ b/lato/application.py @@ -1,11 +1,18 @@ import logging -from collections.abc import Callable -from typing import Any, Optional, Union, List -from lato.types import DependencyIdentifier +from collections.abc import Awaitable, Callable +from typing import Any, Optional, Union + from lato.application_module import ApplicationModule from lato.dependency_provider import BasicDependencyProvider, DependencyProvider -from lato.message import Command, Event, Message -from lato.transaction_context import TransactionContext +from lato.message import Event, Message +from lato.transaction_context import ( + ComposerFunction, + MiddlewareFunction, + OnEnterTransactionContextCallback, + OnExitTransactionContextCallback, + TransactionContext, +) +from lato.types import DependencyIdentifier log = logging.getLogger(__name__) @@ -39,11 +46,15 @@ def __init__( self.dependency_provider = ( dependency_provider or self.dependency_provider_factory(**kwargs) ) - self._transaction_context_factory = None - self._on_enter_transaction_context = lambda ctx: None - self._on_exit_transaction_context = lambda ctx, exception=None: None - self._transaction_middlewares: List[Callable] = [] - self._composers: dict[Union[Message, str], Callable] = {} + self._transaction_context_factory: Optional[Callable] = None + self._on_enter_transaction_context: Optional[ + OnEnterTransactionContextCallback + ] = None + self._on_exit_transaction_context: Optional[ + OnExitTransactionContextCallback + ] = None + self._transaction_middlewares: list[MiddlewareFunction] = [] + self._composers: dict[Union[Message, str], ComposerFunction] = {} def get_dependency(self, identifier: DependencyIdentifier) -> Any: """Gets a dependency from the dependency provider. Dependencies can be resolved either by name or by type. @@ -57,7 +68,7 @@ def get_dependency(self, identifier: DependencyIdentifier) -> Any: def __getitem__(self, identifier: DependencyIdentifier) -> Any: return self.get_dependency(identifier) - def call(self, func: Union[Callable, str], *args, **kwargs): + def call(self, func: Union[Callable[..., Any], str], *args, **kwargs) -> Any: """Invokes a function with `args` and `kwargs` within the :class:`TransactionContext`. If `func` is a string, then it is an alias, and the corresponding handler for the alias is retrieved. Any missing arguments are provided by the dependency provider of a transaction context, @@ -81,6 +92,32 @@ def call(self, func: Union[Callable, str], *args, **kwargs): result = ctx.call(func, *args, **kwargs) return result + async def call_async( + self, func: Union[Callable[..., Awaitable[Any]], str], *args, **kwargs + ) -> Any: + """Invokes an async function with `args` and `kwargs` within the :class:`TransactionContext`. + If `func` is a string, then it is an alias, and the corresponding handler for the alias is retrieved. + Any missing arguments are provided by the dependency provider of a transaction context, + and args and kwargs parameters. + + :param func: The async function to invoke, or an alias. + :param args: Arguments to pass to the function. + :param kwargs: Keyword arguments to pass to the function. + + :return: The result of the invoked function. + + :raises ValueError: If an alias is provided, but no corresponding handler is found. + """ + if isinstance(func, str): + try: + func = next(self.iterate_handlers_for(alias=func)) + except StopIteration: + raise ValueError(f"Handler not found", func) + + async with self.transaction_context() as ctx: + result = await ctx.call_async(func, *args, **kwargs) + return result + def execute(self, message: Message) -> Any: """Executes a command within the :class:`TransactionContext`. Use :func:`handler` decorator to register a handler for the command. @@ -96,19 +133,67 @@ def execute(self, message: Message) -> Any: result = ctx.execute(message) return result + async def execute_async(self, message: Message) -> Any: + """Asynchronously executes a command within the :class:`TransactionContext`. + Use :func:`handler` decorator to register a handler for the command. + If a command is handled by multiple handlers, then the final result is + composed to a single return value using :func:`compose` decorator. + + :param message: The message to be executed (usually, a :class:`Command` or :class:`Query` subclass). + :return: The result of the invoked message handler. + + :raises: ValueError: If no handlers are found for the message. + """ + async with self.transaction_context() as ctx: + result = await ctx.execute(message) + return result + def emit(self, event: Event) -> dict[Callable, Any]: """Deprecated. Use `publish()` instead.""" + return self.publish(event) + + def publish(self, event: Event) -> dict[Callable, Any]: + """ + Publish an event by calling all handlers for that event. + + :param event: The event to publish, or an alias of an event handler to call. + :return: A dictionary mapping handlers to their results. + """ with self.transaction_context() as ctx: - result = ctx.emit(event) + result = ctx.publish(event) + return result + + async def publish_async(self, event: Event) -> dict[Callable, Any]: + """ + Asynchronously publish an event by calling all handlers for that event. + + :param event: The event to publish, or an alias of an event handler to call. + :return: A dictionary mapping handlers to their results. + """ + async with self.transaction_context() as ctx: + result = await ctx.publish_async(event) return result def on_enter_transaction_context(self, func): """ Decorator for registering a function to be called when entering a transaction context - :param func: - :return: + :param func: callback to be called when entering a transaction context + :return: the decorated function + + **Example:** + + >>> from lato import Application, TransactionContext + >>> app = Application() + >>> @app.on_enter_transaction_context + ... def on_enter_transaction_context(ctx: TransactionContext): + ... print('entering transaction context') + ... ctx.set_dependencies(foo="foo") + >>> app.call(lambda foo: print(foo)) + entering transaction context + foo """ + self._on_enter_transaction_context = func return func @@ -116,8 +201,20 @@ def on_exit_transaction_context(self, func): """ Decorator for registering a function to be called when exiting a transaction context - :param func: - :return: + :param func: callback to be called when exiting a transaction context + :return: the decorated function + + **Example:** + + >>> from lato import Application, TransactionContext + >>> app = Application() + >>> + >>> @app.on_exit_transaction_context + ... def on_exit_transaction_context(ctx: TransactionContext, exception=None): + ... print("exiting context") + >>> app.call(lambda: print("calling")) + calling + exiting context """ self._on_exit_transaction_context = func return func @@ -126,8 +223,23 @@ def on_create_transaction_context(self, func): """ Decorator for overriding default transaction context creation - :param func: - :return: + :param func: callback to be called when creating a transaction context + :return: the decorated function + + **Example:** + + >>> from lato import Application, TransactionContext + >>> app = Application() + >>> + >>> class CustomTransactionContext(TransactionContext): + ... pass + >>> + >>> @app.on_create_transaction_context + ... def create_transaction_context(**kwargs): + ... return CustomTransactionContext(**kwargs) + >>> + >>> print(app.transaction_context(foo="bar").__class__.__name__) + CustomTransactionContext """ self._transaction_context_factory = func return func @@ -136,7 +248,7 @@ def transaction_middleware(self, middleware_func): """ Decorator for registering a middleware function to be called when executing a function in a transaction context :param middleware_func: - :return: + :return: the decorated function """ self._transaction_middlewares.insert(0, middleware_func) return middleware_func diff --git a/lato/application_module.py b/lato/application_module.py index 590c77e..281978a 100644 --- a/lato/application_module.py +++ b/lato/application_module.py @@ -1,6 +1,7 @@ import logging from collections import defaultdict from collections.abc import Callable + from lato.message import Message from lato.types import HandlerAlias from lato.utils import OrderedSet @@ -40,30 +41,29 @@ def handler(self, alias: HandlerAlias): >>> my_module = ApplicationModule("my_module") >>> >>> @my_module.handler("my_handler") - >>> def my_handler(): - >>> print("handler called") + ... def my_handler(): + ... print("handler called") >>> >>> app = Application("example") >>> app.include_submodule(my_module) >>> app.call("my_handler") - "handler called" + handler called Example #2: ----------- >>> from lato import ApplicationModule, Command >>> class MyCommand(Command): - >>> ... + ... pass >>> >>> my_module = ApplicationModule("my_module") >>> @my_module.handler(MyCommand) - >>> def my_handler(command: MyCommand): - >>> print("command handler called") + ... def my_handler(command: MyCommand): + ... print("command handler called") >>> >>> app = Application("example") >>> app.include_submodule(my_module) >>> app.execute(MyCommand()) command handler called - """ if isinstance(alias, type): is_message_type = issubclass(alias, Message) diff --git a/lato/testing.py b/lato/testing.py index 35a83f6..d091df1 100644 --- a/lato/testing.py +++ b/lato/testing.py @@ -1,5 +1,5 @@ import contextlib -from typing import Iterator +from collections.abc import Iterator from lato import Application diff --git a/lato/transaction_context.py b/lato/transaction_context.py index 7037a5e..74c6bb9 100644 --- a/lato/transaction_context.py +++ b/lato/transaction_context.py @@ -1,10 +1,10 @@ +import asyncio import logging from collections import OrderedDict -from collections.abc import Callable, Iterator +from collections.abc import Awaitable, Callable, Iterator from functools import partial -from typing import Any, NewType, Optional, Union +from typing import Any, Optional, Union -from lato.types import HandlerAlias from lato.compositon import compose from lato.dependency_provider import ( BasicDependencyProvider, @@ -12,10 +12,18 @@ as_type, ) from lato.message import Message - +from lato.types import HandlerAlias log = logging.getLogger(__name__) +OnEnterTransactionContextCallback = Callable[["TransactionContext"], Awaitable[None]] +OnExitTransactionContextCallback = Callable[ + ["TransactionContext", Optional[Exception]], Awaitable[None] +] +MiddlewareFunction = Callable[["TransactionContext", Callable], Awaitable[Any]] +ComposerFunction = Callable[..., Callable] +HandlersIterator = Callable[[HandlerAlias], Iterator[Callable]] + class TransactionContext: """Transaction context is a context manager for handler execution. @@ -24,15 +32,14 @@ class TransactionContext: defaults to BasicDependencyProvider. **Example:** - >>> from lato import TransactionContext >>> >>> def my_function(param1, param2): - >>> print(param1, param2) + ... print(param1, param2) >>> >>> with TransactionContext(param1="foo") as ctx: - >>> ctx.call(my_function, param2="bar") - foo, bar + ... ctx.call(my_function, param2="bar") + foo bar """ dependency_provider_factory = BasicDependencyProvider @@ -53,19 +60,25 @@ def __init__( ) self.resolved_kwargs: dict[str, Any] = {} self.current_handler: Optional[Callable] = None - self._on_enter_transaction_context = lambda ctx: None - self._on_exit_transaction_context = lambda ctx, exception=None: None - self._middlewares: list[Callable] = [] - self._composers: dict[HandlerAlias, Callable] = {} - self._handlers_iterator: Callable[[HandlerAlias], Iterator[Callable]] = lambda alias: iter([]) + self._on_enter_transaction_context: Optional[ + OnEnterTransactionContextCallback + ] = None + self._on_exit_transaction_context: Optional[ + OnExitTransactionContextCallback + ] = None + self._middlewares: list[MiddlewareFunction] = [] + self._composers: dict[HandlerAlias, ComposerFunction] = {} + self._handlers_iterator: HandlersIterator = lambda alias: iter([]) def configure( self, - on_enter_transaction_context=None, - on_exit_transaction_context=None, - middlewares=None, - composers=None, - handlers_iterator=None, + on_enter_transaction_context: Optional[ + OnEnterTransactionContextCallback + ] = None, + on_exit_transaction_context: Optional[OnExitTransactionContextCallback] = None, + middlewares: Optional[list[MiddlewareFunction]] = None, + composers: Optional[dict[HandlerAlias, ComposerFunction]] = None, + handlers_iterator: Optional[HandlersIterator] = None, ): """Customize the behavior of the transaction context with callbacks, middlewares, and composers. @@ -92,9 +105,25 @@ def begin(self): The callback could be used to set up the transaction-level dependencies (i.e. current time, transaction id), or to start the database transaction. """ - log.debug("Beginning transaction") - """Should be used to start a transaction""" - self._on_enter_transaction_context(self) + log.debug("Transaction started") + if self._on_enter_transaction_context: + if asyncio.iscoroutinefunction(self._on_enter_transaction_context): + raise ValueError( + "Using async on_enter_transaction_context callback with synchronous call. Use call_async instead" + ) + self._on_enter_transaction_context(self) + + async def begin_async(self): + """Asynchronously starts a transaction by calling async `on_enter_transaction_context` callback. + + The callback could be used to set up the transaction-level dependencies (i.e. current time, transaction id), + or to start the database transaction. + """ + log.debug("Transaction started") + if self._on_enter_transaction_context: + result = self._on_enter_transaction_context(self) + if asyncio.iscoroutine(result): + await result def end(self, exception: Optional[Exception] = None): """Ends the transaction context by calling `on_exit_transaction_context` callback, @@ -104,7 +133,30 @@ def end(self, exception: Optional[Exception] = None): :param exception: Optional; The exception to handle at the end of the transaction, if any. """ - self._on_exit_transaction_context(self, exception) + if self._on_exit_transaction_context: + if asyncio.iscoroutinefunction(self._on_exit_transaction_context): + raise ValueError( + "Using async on_exit_transaction_context callback with synchronous call. Use call_async instead" + ) + self._on_exit_transaction_context(self, exception) + + if exception: + log.debug("Ended transaction with exception: {}".format(exception)) + else: + log.debug("Ended transaction") + + async def end_async(self, exception: Optional[Exception] = None): + """Ends the transaction context by calling `on_exit_transaction_context` callback, + optionally passing an exception. + + The callback could be used to commit/end a database transaction. + + :param exception: Optional; The exception to handle at the end of the transaction, if any. + """ + if self._on_exit_transaction_context: + result = self._on_exit_transaction_context(self, exception) + if asyncio.iscoroutine(result): + await result if exception: log.debug("Ended transaction with exception: {}".format(exception)) @@ -121,9 +173,29 @@ def __enter__(self): def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): self.end(exc_val) + async def __aenter__(self): + result = self.begin_async() + if asyncio.iscoroutine(result): + await result + return self + + async def __aexit__(self, exc_type=None, exc_val=None, exc_tb=None): + result = self.end_async(exc_val) + if asyncio.iscoroutine(result): + await result + def _wrap_with_middlewares(self, handler_func): p = handler_func for middleware in self._middlewares: + if not asyncio.iscoroutinefunction( + middleware + ) and asyncio.iscoroutinefunction(handler_func): + raise ValueError( + "Cannot use synchronous middleware with async handler", + middleware, + handler_func, + ) + p = partial(middleware, self, p) return p @@ -147,6 +219,30 @@ def call(self, func: Callable, *func_args: Any, **func_kwargs: Any) -> Any: result = wrapped_handler() return result + async def call_async( + self, func: Callable[..., Awaitable[Any]], *func_args: Any, **func_kwargs: Any + ) -> Any: + """Call an async function with the arguments and keyword arguments. + Missing arguments will be resolved with the dependency provider. + + :param func: The function to call. + :param func_args: Positional arguments to pass to the function. + :param func_kwargs: Keyword arguments to pass to the function. + :return: The result of the function call. + """ + self.dependency_provider.update(ctx=as_type(self, TransactionContext)) + + resolved_kwargs = self.dependency_provider.resolve_func_params( + func, func_args, func_kwargs + ) + self.resolved_kwargs.update(resolved_kwargs) + p = partial(func, **resolved_kwargs) + wrapped_handler = self._wrap_with_middlewares(p) + result = wrapped_handler() + if asyncio.iscoroutine(result): + result = await result + return result + def execute(self, message: Message) -> tuple[Any, ...]: """Executes all handlers bound to the message. Returns a tuple of handlers' return values. @@ -163,11 +259,31 @@ def execute(self, message: Message) -> tuple[Any, ...]: composed_result = self._compose_results(message, values) return composed_result - def emit(self, message: Union[str, Message], *args, **kwargs) -> dict[Callable, Any]: + async def execute_async(self, message: Message) -> tuple[Any, ...]: + """Executes all async handlers bound to the message. Returns a tuple of handlers' return values. + + :param message: The message to be executed. + :return: a tuple of return values from executed handlers + :raises: ValueError: If no handlers are found for the message. + """ + results = await self.publish_async(message) + values = tuple(results.values()) + + if len(values) == 0: + raise ValueError("No handlers found for message", values) + + composed_result = self._compose_results(message, values) + return composed_result + + def emit( + self, message: Union[str, Message], *args, **kwargs + ) -> dict[Callable, Any]: # TODO: mark as obsolete return self.publish(message, *args, **kwargs) - def publish(self, message: Union[str, Message], *args, **kwargs) -> dict[Callable, Any]: + def publish( + self, message: Union[str, Message], *args, **kwargs + ) -> dict[Callable, Any]: """ Publish a message by calling all handlers for that message. @@ -190,6 +306,34 @@ def publish(self, message: Union[str, Message], *args, **kwargs) -> dict[Callabl all_results[handler] = result return all_results + async def publish_async( + self, message: Union[str, Message], *args, **kwargs + ) -> dict[Callable, Awaitable[Any]]: + """ + Asynchronously publish a message by calling all handlers for that message. + + :param message: The message object to publish, or an alias of a handler to call. + :param args: Positional arguments to pass to the handlers. + :param kwargs: Keyword arguments to pass to the handlers. + :return: A dictionary mapping handlers to their results. + """ + message_type = type(message) if isinstance(message, Message) else message + + if isinstance(message, Message): + args = (message, *args) + + all_results = OrderedDict() + # TODO: use asyncio.gather() + for handler in self._handlers_iterator(message_type): # type: ignore + self.set_dependency("message", message) + # FIXME: push and pop current action instead of setting it + self.current_handler = ( + None # FIXME: multiple handlers can be running asynchronously + ) + result = await self.call_async(handler, *args, **kwargs) + all_results[handler] = result + return all_results + def get_dependency(self, identifier: Any) -> Any: """Gets a dependency from the dependency provider""" return self.dependency_provider.get_dependency(identifier) diff --git a/lato/types.py b/lato/types.py index e144f5b..817fd96 100644 --- a/lato/types.py +++ b/lato/types.py @@ -1,4 +1,5 @@ from typing import Union + from lato.message import Message HandlerAlias = Union[type[Message], str] diff --git a/poetry.lock b/poetry.lock index 5e8c39b..9a1e944 100644 --- a/poetry.lock +++ b/poetry.lock @@ -436,22 +436,22 @@ files = [ [[package]] name = "importlib-metadata" -version = "7.0.1" +version = "7.0.2" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, - {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, + {file = "importlib_metadata-7.0.2-py3-none-any.whl", hash = "sha256:f4bc4c0c070c490abf4ce96d715f68e95923320370efb66143df00199bb6c100"}, + {file = "importlib_metadata-7.0.2.tar.gz", hash = "sha256:198f568f3230878cb1b44fbd7975f87906c22336dba2e4a7f05278c281fbd792"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "iniconfig" @@ -730,38 +730,38 @@ files = [ [[package]] name = "mypy" -version = "1.8.0" +version = "1.9.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, - {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, - {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, - {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, - {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, - {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, - {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, - {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, - {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, - {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, - {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, - {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, - {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, - {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, - {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, - {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, - {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, - {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, - {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, ] [package.dependencies] @@ -839,13 +839,13 @@ files = [ [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] @@ -890,13 +890,13 @@ files = [ [[package]] name = "pydantic" -version = "2.6.3" +version = "2.6.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.6.3-py3-none-any.whl", hash = "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a"}, - {file = "pydantic-2.6.3.tar.gz", hash = "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f"}, + {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, + {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, ] [package.dependencies] @@ -1049,6 +1049,24 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.23.5.post1" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"}, + {file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pywin32-ctypes" version = "0.2.2" @@ -1085,6 +1103,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1525,20 +1544,20 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "zipp" -version = "3.17.0" +version = "3.18.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, - {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, + {file = "zipp-3.18.0-py3-none-any.whl", hash = "sha256:c1bb803ed69d2cce2373152797064f7e79bc43f0a3748eb494096a867e0ebf79"}, + {file = "zipp-3.18.0.tar.gz", hash = "sha256:df8d042b02765029a09b157efd8e820451045890acc30f8e37dd2f94a060221f"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "4dc665b21f937371b2b9bed39492931d64ad52c5c3b70c9c89a41d0019120843" +content-hash = "f384c2b060d4c2df66c1d4c0e76efd05061e525d0831ae4c6071744a185aef58" diff --git a/pyproject.toml b/pyproject.toml index 4252133..39cd24b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "lato" -version = "0.9.3" +version = "0.10" description = "Lato is a Python microframework designed for building modular monoliths and loosely coupled applications." authors = ["Przemysław Górecki "] readme = "README.md" @@ -21,6 +21,7 @@ license = "MIT" python = "^3.9" pydantic = "^2.4.2" mergedeep = "^1.3.4" +pytest-asyncio = "^0.23.5.post1" [tool.poetry.group.dev.dependencies] build = "^1.0.3" diff --git a/tests/test_application.py b/tests/test_application.py index a0295f7..63987c7 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -192,3 +192,19 @@ def handle_my_event(event: MyEvent): task = MyEvent(message="foo") assert app.emit(task) == {handle_my_event: "handled foo"} + + +def test_create_transaction_context_callback(): + from lato import Application, TransactionContext + + app = Application() + + class CustomTransactionContext(TransactionContext): + pass + + @app.on_create_transaction_context + def create_transaction_context(**kwargs): + return CustomTransactionContext(**kwargs) + + ctx = app.transaction_context(foo="bar") + assert isinstance(ctx, CustomTransactionContext) diff --git a/tests/test_application_async.py b/tests/test_application_async.py new file mode 100644 index 0000000..17cd3ce --- /dev/null +++ b/tests/test_application_async.py @@ -0,0 +1,195 @@ +import asyncio +from collections.abc import Callable + +import pytest + +from lato import Application, Command, TransactionContext + + +@pytest.mark.asyncio +async def test_decorated_async_handler_is_coroutine_function(): + app = Application() + + @app.handler("foo") + async def foo(): + return 1 + + @app.handler("bar") + def bar(): + return 2 + + assert asyncio.iscoroutinefunction(foo) is True + assert await foo() == 1 + assert asyncio.iscoroutinefunction(bar) is False + assert bar() == 2 + + +@pytest.mark.asyncio +async def test_call_async_handler(): + app = Application() + + async def async_foo(): + return 1 + + result = await app.call_async(async_foo) + + assert result == 1 + + +@pytest.mark.asyncio +async def test_call_async_alias_with_async_context(): + trace = [] + app = Application(trace=trace) + + @app.on_enter_transaction_context + async def on_enter_transaction_context(ctx: TransactionContext): + await asyncio.sleep(0.001) + trace.append("on_enter_transaction_context") + + @app.on_exit_transaction_context + async def on_exit_transaction_context(ctx, exception): + await asyncio.sleep(0.001) + trace.append("on_exit_transaction_context") + + @app.transaction_middleware + async def async_middleware(ctx: TransactionContext, call_next: Callable): + await asyncio.sleep(0.001) + trace.append("before_call_next") + r = await call_next() + trace.append("after_call_next") + return r + + @app.handler("foo") + async def async_foo(): + await asyncio.sleep(0.001) + trace.append("async_foo") + return 1 + + result = await app.call_async("foo") + + assert result == 1 + assert trace == [ + "on_enter_transaction_context", + "before_call_next", + "async_foo", + "after_call_next", + "on_exit_transaction_context", + ] + + +@pytest.mark.asyncio +async def test_call_sync_alias_with_async_context(): + trace = [] + app = Application(trace=trace) + + @app.on_enter_transaction_context + async def on_enter_transaction_context(ctx: TransactionContext): + await asyncio.sleep(0.001) + trace.append("on_enter_transaction_context") + + @app.on_exit_transaction_context + async def on_exit_transaction_context(ctx, exception): + await asyncio.sleep(0.001) + trace.append("on_exit_transaction_context") + + @app.handler("foo") + def async_foo(): + trace.append("sync_foo") + + await app.call_async("foo") + + assert trace == [ + "on_enter_transaction_context", + "sync_foo", + "on_exit_transaction_context", + ] + + +@pytest.mark.asyncio +async def test_execute_async_command_handler_with_async_context(): + trace = [] + app = Application(trace=trace) + + class FooCommand(Command): + ... + + @app.on_enter_transaction_context + async def on_enter_transaction_context(ctx: TransactionContext): + await asyncio.sleep(0.001) + trace.append("on_enter_transaction_context") + + @app.on_exit_transaction_context + async def on_exit_transaction_context(ctx, exception): + await asyncio.sleep(0.001) + trace.append("on_exit_transaction_context") + + @app.transaction_middleware + async def async_middleware(ctx: TransactionContext, call_next: Callable): + await asyncio.sleep(0.001) + trace.append("before_call_next") + r = await call_next() + trace.append("after_call_next") + return r + + @app.handler(FooCommand) + async def async_foo(command: FooCommand): + await asyncio.sleep(0.001) + trace.append("async_foo") + return 1 + + result = await app.execute_async(FooCommand()) + + assert result == 1 + assert trace == [ + "on_enter_transaction_context", + "before_call_next", + "async_foo", + "after_call_next", + "on_exit_transaction_context", + ] + + +@pytest.mark.asyncio +async def test_call_async_handler_with_sync_context(): + trace = [] + app = Application(trace=trace) + + @app.on_enter_transaction_context + def on_enter_transaction_context(ctx: TransactionContext): + trace.append("on_enter_transaction_context") + + @app.on_exit_transaction_context + def on_exit_transaction_context(ctx, exception): + trace.append("on_exit_transaction_context") + + async def async_foo(): + await asyncio.sleep(0.001) + trace.append("async_foo") + return 1 + + result = await app.call_async(async_foo) + + assert result == 1 + assert trace == [ + "on_enter_transaction_context", + "async_foo", + "on_exit_transaction_context", + ] + + +@pytest.mark.asyncio +async def test_call_async_handler_with_sync_middleware(): + app = Application() + + @app.transaction_middleware + def sync_middleware(ctx: TransactionContext, call_next: Callable): + pass + + @app.handler("async_foo") + async def async_foo(): + await asyncio.sleep(0.001) + return 1 + + with pytest.raises(ValueError): + # cannot use synchronous middleware with async handler + await app.call_async("async_foo")