From c954c2825bd9056075cf152bd618c2e748721c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20G=C3=B3recki?= Date: Sun, 24 Mar 2024 20:20:37 +0100 Subject: [PATCH] change composer interface. update documentation --- docs/concurrency.rst | 11 ++- docs/conf.py | 2 +- docs/index.rst | 2 + docs/key_concepts/cq_composition.rst | 16 +++- docs/key_concepts/dependency_injection.rst | 46 +++++++-- docs/key_concepts/events.rst | 44 +++++++++ docs/key_concepts/handlers.rst | 42 +++++---- docs/key_concepts/index.rst | 4 +- docs/key_concepts/middlewares.rst | 76 ++++++++++++++- docs/key_concepts/modularity.rst | 103 +++++++++++++++++++++ docs/key_concepts/src/example1_app.py | 5 +- docs/key_concepts/src/example1_module.py | 2 +- docs/key_concepts/transaction_context.rst | 89 +++--------------- docs/tutorial/src/application.py | 2 +- docs/tutorial/tutorial_02.rst | 4 +- docs/tutorial/tutorial_03.rst | 2 +- docs/tutorial/tutorial_05.rst | 4 +- examples/example5.py | 30 ++++++ examples/example6.py | 40 ++++++++ lato/__init__.py | 2 +- lato/application.py | 33 ++++++- lato/application_module.py | 9 +- lato/compositon.py | 10 +- lato/transaction_context.py | 49 ++++++---- lato/utils.py | 12 +++ pyproject.toml | 2 +- tests/test_application.py | 2 +- tests/test_composition.py | 15 ++- 28 files changed, 499 insertions(+), 159 deletions(-) create mode 100644 docs/key_concepts/events.rst create mode 100644 examples/example5.py create mode 100644 examples/example6.py diff --git a/docs/concurrency.rst b/docs/concurrency.rst index 6574540..c69d728 100644 --- a/docs/concurrency.rst +++ b/docs/concurrency.rst @@ -15,14 +15,19 @@ Below is an example of async application: import asyncio import logging + import sys from lato import Application, TransactionContext - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig(level=logging.INFO, format='%(name)s%(message)s') root_logger = logging.getLogger("toy") - + stream_handler = logging.StreamHandler(stream=sys.stdout) + stream_handler.setFormatter(logging.Formatter('%(levelname)s:%(name)s:%(message)s')) + root_logger.addHandler(stream_handler) + + app = Application() - + class Counter: def __init__(self): diff --git a/docs/conf.py b/docs/conf.py index 487c365..6d7ba64 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,8 +6,8 @@ doc_root = dirname(__file__) tutorial_src_root = sep.join([doc_root, "tutorial", "src"]) -sys.path.insert(0, doc_root) sys.path.insert(0, "..") +sys.path.insert(0, doc_root) # Configuration file for the Sphinx documentation builder. # diff --git a/docs/index.rst b/docs/index.rst index d213ab9..424e4a3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,8 @@ Core Features - **Minimalistic**: Intuitive and lean API for rapid development without the bloat. +- **Concurrency support**: built-in support for coroutines declared with async / await syntax. + Contents -------- diff --git a/docs/key_concepts/cq_composition.rst b/docs/key_concepts/cq_composition.rst index ee9e9ec..c4df428 100644 --- a/docs/key_concepts/cq_composition.rst +++ b/docs/key_concepts/cq_composition.rst @@ -3,4 +3,18 @@ Message Composition and Decomposition ===================================== -lorem ipsum \ No newline at end of file +If there are multiple command handlers (i.e. in different modules) for the same Command, all handlers for that +command will be executed (decomposition), and the results of command handlers will be merged into single response +(composition). + +.. literalinclude:: ../../examples/example5.py + +By default, handler responses of type ``dict`` will be recursively merged into a single dict, and responses of type +``list`` will be merged a into single list. + + +You can use ``Application.compose`` decorator to declare a custom composer. A custom composer will receive kwargs +with names of the modules handling the response. + +.. literalinclude:: ../../examples/example6.py + diff --git a/docs/key_concepts/dependency_injection.rst b/docs/key_concepts/dependency_injection.rst index 6a91652..0c20b38 100644 --- a/docs/key_concepts/dependency_injection.rst +++ b/docs/key_concepts/dependency_injection.rst @@ -4,14 +4,42 @@ Dependency Injection ==================== -work in progress +:func:`~lato.DependencyProvider` is an interface for any concrete provider capable of resolving and matching function +parameters. Both :func:`~lato.Application` and :func:`~lato.TransactionContext` internally use dependency provider +to resolve handler parameters. By default use dict based implementation +:func:`~lato.dependency_provider.BasicDependencyProvider`. - Example: - -------- - >>> from lato.dependency_provider import BasicDependencyProvider - >>> - >>> def my_func(a: str) -> str: - >>> print(f"{a} {b}") - >>> - >>> assert BasicDependencyProvider(a="foo").resolve_func_params(my_func) == {} +This code demonstrates basic functionality of a dependency provider + +.. testcode:: + + from lato.dependency_provider import BasicDependencyProvider + + class FooService(): + pass + + def a_handler(service: FooService): + pass + + foo_service = FooService() + dp = BasicDependencyProvider(foo_service=foo_service) + assert dp[FooService] is foo_service + assert dp["foo_service"] is foo_service + + assert dp.resolve_func_params(a_handler) == {'service': foo_service} + +``lagom`` integration +--------------------- + +This code showcases a dependency provider based on ``lagom``: + +.. literalinclude:: ../../examples/example3/lagom_integration.py + + +``dependency_injector`` integration +----------------------------------- + +This code showcases a dependency provider based on ``dependency_injector``: + +.. literalinclude:: ../../examples/example3/dependency_injector_integration.py diff --git a/docs/key_concepts/events.rst b/docs/key_concepts/events.rst new file mode 100644 index 0000000..ba53cb3 --- /dev/null +++ b/docs/key_concepts/events.rst @@ -0,0 +1,44 @@ +Events and publish-subscribe +============================ + +Application modules can interact by publishing and subscribing to messages. Events can be published +using a ``TransactionContext``, and received by an event handler. There can be multiple handlers bound to +a single event. If the application is receiving an event from an external source, +it should be processed using ``Application.publish()``: + + +.. testcode:: + + from lato import Application, ApplicationModule, Event, Command, TransactionContext + + class SampleCommand(Command): + pass + + class FooHappened(Event): + source: str + + foo_module = ApplicationModule(name="foo") + @foo_module.handler(SampleCommand) + def call_foo(command: SampleCommand, ctx: TransactionContext): + print("handling foo") + ctx.publish(FooHappened(source="foo")) + + bar_module = ApplicationModule(name="bar") + @bar_module.handler(FooHappened) + def on_foo_happened(event: FooHappened): + print(f"handling event from {event.source}") + + foobar = Application() + foobar.include_submodule(foo_module) + foobar.include_submodule(bar_module) + + foobar.execute(SampleCommand()) + foobar.publish(FooHappened(source="external source")) + +And the output is: + +.. testoutput:: + + handling foo + handling event from foo + handling event from external source \ No newline at end of file diff --git a/docs/key_concepts/handlers.rst b/docs/key_concepts/handlers.rst index e32504d..d88f499 100644 --- a/docs/key_concepts/handlers.rst +++ b/docs/key_concepts/handlers.rst @@ -19,17 +19,11 @@ In this approach, a function is passed to a :func:`Application.call` as is: from lato import Application def foo(): - print("called directly") + return "called directly" app = Application("example") - app.call(foo) - -And the output is: - -.. testoutput:: - - called + assert app.call(foo) == "called directly" Calling a function using an *alias* @@ -38,26 +32,34 @@ Calling a function using an *alias* In this approach, a function is first decorated with :func:`ApplicationM.handler`, and then called using an alias: .. testcode:: - from lato import ApplicationModule from lato import Application app = Application("example") - @app.handler("alias_of_foo") - def foo(): - print("called via alias") + @app.handler("alias_of_bar") + def bar(): + return "called via alias" - app.call("alias_of_foo") + app.call("alias_of_bar") == "called via alias" -And the output is: -.. testoutput:: +Calling the function using a command +-------------------------------------------- - called via alias +In this approach, a command is declared, then a :func:`Application.handler` decorator is used to +associate the command with its handler. +.. testcode:: -Calling the function using a message handler --------------------------------------------- + from lato import Application, Command + app = Application("example") + + class SampleCommand(Command): + x: int -In this approach, a message is declared, then a :func:`Application.handler` decorator is used to -associate the message with its handler. \ No newline at end of file + + @app.handler(SampleCommand) + def sample_command_handler(command: SampleCommand): + return f"called sample command with x={command.x}" + + app.execute(SampleCommand(x=1)) == "called sample command with x=1" \ No newline at end of file diff --git a/docs/key_concepts/index.rst b/docs/key_concepts/index.rst index 24e2791..581bce0 100644 --- a/docs/key_concepts/index.rst +++ b/docs/key_concepts/index.rst @@ -5,8 +5,10 @@ Key Concepts :maxdepth: 3 handlers + modularity + events transaction_context - dependency_injection middlewares + dependency_injection cq_composition diff --git a/docs/key_concepts/middlewares.rst b/docs/key_concepts/middlewares.rst index c859f80..24508d7 100644 --- a/docs/key_concepts/middlewares.rst +++ b/docs/key_concepts/middlewares.rst @@ -1,4 +1,74 @@ -Transaction Middleware -====================== +Transaction callbacks and middlewares +===================================== -... \ No newline at end of file +In most cases, the handler is executed by calling one of the Application methods: :func:`~lato.Application.call`, +:func:`~lato.Application.execute`, or :func:`~lato.Application.publish`, which creates the transaction context under the +hood. + +When :func:`~lato.Application.call` or similar method is executed, the lifecycle of handler execution is as following: + +1. ``on_enter_transaction_context`` callback is invoked + +2. ``transaction_middleware`` functions, wrapping the handler are invoked + +3. handler function is invoked + +4. ``on_exit_transaction_context`` callback is invoked + +The application can be configured to use any of the callbacks by using decorators: + +.. testcode:: + + from typing import Optional, Callable + from lato import Application, TransactionContext + + + app = Application() + + @app.on_enter_transaction_context + def on_enter_transaction_context(ctx: TransactionContext): + print("starting transaction") + + @app.on_exit_transaction_context + def on_exit_transaction_context(ctx: TransactionContext, + exception: Optional[Exception]=None): + print("exiting transaction context") + + @app.transaction_middleware + def middleware1(ctx: TransactionContext, call_next: Callable): + print("entering middleware1") + result = call_next() # will call middleware2 + print("exiting middleware1") + return result + + @app.transaction_middleware + def middleware2(ctx: TransactionContext, call_next: Callable): + print("entering middleware2") + result = call_next() # will call the handler + print("exiting middleware2") + return f"[{result}]" + + def to_uppercase(s): + print("calling handler") + return s.upper() + + + print(app.call(to_uppercase, "foo")) + +This will generate the output: + +.. testoutput:: + + starting transaction + entering middleware1 + entering middleware2 + calling handler + exiting middleware2 + exiting middleware1 + exiting transaction context + [FOO] + +Any of the callbacks is optional, and there can be multiple ``transaction_middleware`` callbacks. +``on_enter_transaction_context`` is a good place to set up the transaction level dependencies, i.e. the dependencies +that change with every transaction - correlation id, current time, database session, etc. ``call_next`` is a ``functools.partial`` object, +so you can inspect is arguments using ``call_next.args`` and ``call_next.keywords``. diff --git a/docs/key_concepts/modularity.rst b/docs/key_concepts/modularity.rst index e69de29..4895b52 100644 --- a/docs/key_concepts/modularity.rst +++ b/docs/key_concepts/modularity.rst @@ -0,0 +1,103 @@ +Modularity +========== + +Breaking down applications into separate, self-contained modules that represent distinct bounded contexts is a +common practice. These modules encapsulate cohesive components such as entities, repositories, and services, +ensuring that each part of the application remains focused on its specific functionality and responsibilities. +This modular approach facilitates maintainability, scalability and reusability by drawing clear boundaries +between different parts of the system. + +Basics +------ + +In *lato*, *Application* can be decomposed into *ApplicationModules* like so: + +.. testcode:: + + from lato import Application, ApplicationModule + + foo_module = ApplicationModule(name="foo") + @foo_module.handler("foo") + def call_foo(): + print("foo") + + bar_module = ApplicationModule(name="bar") + @bar_module.handler("bar") + def call_bar(): + print("bar") + + foobar = Application() + foobar.include_submodule(foo_module) + foobar.include_submodule(bar_module) + + foobar.call("foo") + foobar.call("bar") + +.. testoutput:: + + foo + bar + +Application modules can create hierarchies, i. e. top level module can be further composed into submodules if needed. + +Application module structure +---------------------------- + +The application module can be implemented as a Python package:: + + |- application.py + |- sample_module/ + |- __init__.py + |- commands.py + |- events.py + +.. code-block:: python + + # sample_module/__init__.py + import importlib + from lato import ApplicationModule + + a_module = ApplicationModule("sample") + importlib.import_module("sample_module.commands") + importlib.import_module("sample_module.events") + +.. code-block:: python + + # sample_module/commands.py + from lato import Command + from sample_module import a_module + + class SampleCommand(Command): + pass + + @a_module.handler(SampleCommand) + def handle_sample_command(command: SampleCommand): + pass + +.. code-block:: python + + # sample_module/events.py + from lato import Event + from sample_module import a_module + + class SampleEvent(Event): + pass + + @a_module.handler(SampleEvent) + def on_sample_event(event: SampleEvent): + pass + +.. code-block:: python + + # application.py + from lato import Application + from sample_module import a_module + from sample_module.co + + app = Application() + app.include_submodule(a_module) + + @a_module.handler(SampleEvent) + def on_sample_event(event: SampleEvent): + pass + diff --git a/docs/key_concepts/src/example1_app.py b/docs/key_concepts/src/example1_app.py index 5abc1d5..07c32f5 100644 --- a/docs/key_concepts/src/example1_app.py +++ b/docs/key_concepts/src/example1_app.py @@ -1,8 +1,9 @@ -from lato import Application from example1_module import my_module +from lato import Application + app = Application("alias_example") app.include_submodule(my_module) -app.call("alias_of_foo") \ No newline at end of file +app.call("alias_of_foo") diff --git a/docs/key_concepts/src/example1_module.py b/docs/key_concepts/src/example1_module.py index 1419087..cb8a136 100644 --- a/docs/key_concepts/src/example1_module.py +++ b/docs/key_concepts/src/example1_module.py @@ -5,4 +5,4 @@ @my_module.handler("alias_of_foo") def foo(): - print("called via alias") \ No newline at end of file + print("called via alias") diff --git a/docs/key_concepts/transaction_context.rst b/docs/key_concepts/transaction_context.rst index ccef89e..18dc92b 100644 --- a/docs/key_concepts/transaction_context.rst +++ b/docs/key_concepts/transaction_context.rst @@ -3,10 +3,13 @@ Transaction Context =================== -``TransactionContext`` is a core concept of ``lato``. It's main purpose is to inject dependencies into any function. +:func:`~lato.TransactionContext` is a core building block in ``lato``. It's main purpose is to inject dependencies into a handler function. When ``TransactionContext`` is calling a function using `call` method, it will inspect all its arguments and -it will try to inject its arguments (dependencies) into it. Any arguments passed to ``TransactionContext.call`` that -match to the function signature will be passed to the called function. +it will try to inject all matching dependencies into it. This greatly simplifies testing. + +You can instantiate :func:`~lato.TransactionContext` with any args and kwargs. These arguments will be injected to a handler +during a call. Any arguments passed to ``TransactionContext.call`` that match the function +signature will be passed to the called function, and will replace the ones passed in the constructor. :: @@ -20,14 +23,14 @@ match to the function signature will be passed to the called function. assert result == "Hello, Alice!" -As you can see, ``greeting`` and ``name`` arguments in ``TransactionContext.call`` are actually passed to the ``greet`` function. +As you can see, ``greeting`` and ``name`` arguments in ``TransactionContext.call()`` are actually passed to the ``greet`` function. You can use both keyword and positional arguments:: ctx.call(greet, "Alice", "Hello") ctx.call(greet, "Alice", greeting="Hello") -Instead of passing passing dependencies to ``TransactionContext.call``, it's often more convenient to pass them to ``TransactionContext`` +Instead of passing passing dependencies to ``TransactionContext.call()``, it's often more convenient to pass them to ``TransactionContext`` constructor:: from lato import TransactionContext @@ -40,10 +43,7 @@ constructor:: print(ctx.call(greet, "Charlie")) .. note:: - Any arguments passed to ``TransactionContext.call`` will override arguments passed to the constructor. - - -``TransactionContext`` is capable of injecting positional arguments, keyworded arguments, and typed arguments. + Any arguments passed to ``TransactionContext.call()`` will override arguments passed to the TransactionContext constructor. :: @@ -57,11 +57,8 @@ constructor:: ... ctx = TransactionContext(foo_service=FooService()) - ctx.call(do_something) - ctx.call(do_something_else) - - - + ctx.call(do_something) # will inject an instance of FooService using name `foo_service` + ctx.call(do_something_else) # will inject an instance of FooService using type `FooService` ``TransactionContext`` is also a context manager, so you can use it with ``with`` statement:: @@ -75,65 +72,9 @@ constructor:: print(ctx.call(greet, "Bob")) print(ctx.call(greet, "Charlie")) -``TransactionContext`` is also a decorator, so you can use it to decorate any function:: - - from lato import TransactionContext - - @TransactionContext(greeting="Hola") - def greet(name, greeting): - return f"{greeting}, {name}!" - - print(greet("Bob")) - print(greet("Charlie")) - -``TransactionContext`` is also a class, so you can inherit from it:: - - from lato import TransactionContext - - class MyTransactionContext(TransactionContext): - def __init__(self, greeting, **kwargs): - super().__init__(**kwargs) - self.greeting = greeting - - def greet(self, name): - return f"{self.greeting}, {name}!" - - ctx = MyTransactionContext(greeting="Hola") - print(ctx.greet("Bob")) - print(ctx.greet("Charlie")) - -``TransactionContext`` is also a context manager, so you can use it with ``with`` statement:: - - from lato import TransactionContext - - class MyTransactionContext(TransactionContext): - def __init__(self, greeting, **kwargs): - super().__init__(**kwargs) - self.greeting = greeting - - def greet(self, name): - return f"{self.greeting}, {name}!" - - with MyTransactionContext(greeting="Hola") as ctx: - print(ctx.greet("Bob")) - print(ctx.greet("Charlie")) - -``TransactionContext`` is also a decorator, so you can use it to decorate any function:: - - from lato import TransactionContext - - class MyTransactionContext(TransactionContext): - def __init__(self, greeting, **kwargs): - super().__init__(**kwargs) - self.greeting = greeting - - def greet(self, name): - return f"{self.greeting}, {name}!" - - @MyTransactionContext(greeting="Hola") - def greet(name, greeting): - return f"{greeting}, {name}!" - print(greet("Bob")) - print +It is rarely needed to instantiate transaction context directly. In most cases, it is sufficient to call +any of the Application methods: :func:`~lato.Application.call`, +:func:`~lato.Application.execute`, or :func:`~lato.Application.publish`, which creates the transaction context under the +hood. \ No newline at end of file diff --git a/docs/tutorial/src/application.py b/docs/tutorial/src/application.py index 95b5db6..89ac775 100644 --- a/docs/tutorial/src/application.py +++ b/docs/tutorial/src/application.py @@ -38,7 +38,7 @@ def on_exit_transaction_context(ctx: TransactionContext, exception=None): def logging_middleware(ctx: TransactionContext, call_next: Callable) -> Any: handler = ctx.current_handler message_name = ctx.get_dependency("message").__class__.__name__ - handler_name = f"{handler.__module__}.{handler.__name__}" + handler_name = f"{handler.source}.{handler.fn.__name__}" print(f"Executing {handler_name}({message_name})") result = call_next() print(f"Result from {handler_name}: {result}") diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index f929d04..31d33be 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -32,8 +32,8 @@ receive the read model. If other modules need to make changes, they should not d Instead, they should request the modification by sending a message to the todos module. -Todo repository ---------------- +Repository +---------- Our design pattern of choice for storing and retrieving entities is the *repository pattern*. diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 22923d6..5ae2ed1 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -1,4 +1,4 @@ -Todos Module +First Module ============ Conceptually, the application module is a collection of message handlers. The handler is a function that accepts a message, and other diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst index 9e2337d..4e00c08 100644 --- a/docs/tutorial/tutorial_05.rst +++ b/docs/tutorial/tutorial_05.rst @@ -1,5 +1,5 @@ -Putting all things together -=========================== +Putting it all together +======================= Up to this point, we have defined messages, their corresponding handlers, and organized them into modules. Now it's finally time to put everything together. diff --git a/examples/example5.py b/examples/example5.py new file mode 100644 index 0000000..d67214e --- /dev/null +++ b/examples/example5.py @@ -0,0 +1,30 @@ +from lato import Application, ApplicationModule, Command + + +class GetItemDetails(Command): + item_id: str + + +pricing_module = ApplicationModule("pricing") + + +@pricing_module.handler(GetItemDetails) +def get_item_price(command: GetItemDetails): + prices = {"pencil": 1, "pen": 2} + return {"price": prices[command.item_id]} + + +warehouse_module = ApplicationModule("warehouse") + + +@warehouse_module.handler(GetItemDetails) +def get_item_stock(command: GetItemDetails): + stocks = {"pencil": 100, "pen": 80} + return {"stock": stocks[command.item_id]} + + +app = Application() +app.include_submodule(pricing_module) +app.include_submodule(warehouse_module) + +assert app.execute(GetItemDetails(item_id="pen")) == {"price": 2, "stock": 80} diff --git a/examples/example6.py b/examples/example6.py new file mode 100644 index 0000000..0b5773e --- /dev/null +++ b/examples/example6.py @@ -0,0 +1,40 @@ +from lato import Application, ApplicationModule, Command, TransactionContext +from lato.compositon import compose + +class GetAllItemDetails(Command): + pass + + +pricing_module = ApplicationModule("pricing") + +@pricing_module.handler(GetAllItemDetails) +def get_item_price(command: GetAllItemDetails): + prices = {'pencil': 1, 'pen': 2} + return prices + + +warehouse_module = ApplicationModule("warehouse") + +@warehouse_module.handler(GetAllItemDetails) +def get_item_stock(command: GetAllItemDetails): + stocks = {'pencil': 100, 'pen': 80} + return stocks + + +app = Application() +app.include_submodule(pricing_module) +app.include_submodule(warehouse_module) + +@app.compose(GetAllItemDetails) +def compose_item_details(pricing, warehouse): + assert pricing == {'pencil': 1, 'pen': 2} + assert warehouse == {'pencil': 100, 'pen': 80} + + details = [dict(item_id=x, price=pricing[x], stock=warehouse[x]) for x in pricing.keys()] + return details + + +assert app.execute(GetAllItemDetails()) == [ + {'item_id': 'pencil', 'price': 1, 'stock': 100}, + {'item_id': 'pen', 'price': 2, 'stock': 80} +] diff --git a/lato/__init__.py b/lato/__init__.py index 340bad4..46c4ac3 100644 --- a/lato/__init__.py +++ b/lato/__init__.py @@ -9,7 +9,7 @@ from .message import Command, Event, Query from .transaction_context import TransactionContext -__version__ = "0.10.0" +__version__ = "0.11.0" __all__ = [ "Application", "ApplicationModule", diff --git a/lato/application.py b/lato/application.py index 0d4fcd9..1377707 100644 --- a/lato/application.py +++ b/lato/application.py @@ -84,7 +84,8 @@ def call(self, func: Union[Callable[..., Any], str], *args, **kwargs) -> Any: """ if isinstance(func, str): try: - func = next(self.iterate_handlers_for(alias=func)) + message_handler = next(self.iterate_handlers_for(alias=func)) + func = message_handler.fn except StopIteration: raise ValueError(f"Handler not found", func) @@ -110,7 +111,8 @@ async def call_async( """ if isinstance(func, str): try: - func = next(self.iterate_handlers_for(alias=func)) + message_handler = next(self.iterate_handlers_for(alias=func)) + func = message_handler.fn except StopIteration: raise ValueError(f"Handler not found", func) @@ -249,13 +251,38 @@ 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: the decorated function + + **Example:** + + >>> from typing import Callable + >>> from lato import Application, TransactionContext + >>> + >>> app = Application() + + >>> @app.transaction_middleware + ... def middleware1(ctx: TransactionContext, call_next: Callable): + ... ... + """ self._transaction_middlewares.insert(0, middleware_func) return middleware_func def compose(self, alias): """ - Decorator for composing results of handlers identified by an alias + Decorator for composing results of handlers identified by an alias. + + **Example:** + + >>> from lato import Application, Command, TransactionContext + + >>> class SomeCommand(Command): + ... pass + >>> + >>> app = Application() + + >>> @app.compose(SomeCommand) + ... def middleware1(**kwargs): + ... ... """ def decorator(func): diff --git a/lato/application_module.py b/lato/application_module.py index 281978a..78ce813 100644 --- a/lato/application_module.py +++ b/lato/application_module.py @@ -3,8 +3,9 @@ from collections.abc import Callable from lato.message import Message +from lato.transaction_context import MessageHandler from lato.types import HandlerAlias -from lato.utils import OrderedSet +from lato.utils import OrderedSet, string_to_kwarg_name log = logging.getLogger(__name__) @@ -19,6 +20,10 @@ def __init__(self, name: str): self._handlers: defaultdict[str, OrderedSet[Callable]] = defaultdict(OrderedSet) self._submodules: OrderedSet[ApplicationModule] = OrderedSet() + @property + def identifier(self): + return string_to_kwarg_name(self.name) + def include_submodule(self, a_module: "ApplicationModule"): """Adds a child submodule to this module. @@ -93,7 +98,7 @@ def decorator(func): def iterate_handlers_for(self, alias: str): if alias in self._handlers: for handler in self._handlers[alias]: - yield handler + yield MessageHandler(source=self.identifier, message=alias, fn=handler) for submodule in self._submodules: try: yield from submodule.iterate_handlers_for(alias) diff --git a/lato/compositon.py b/lato/compositon.py index d2e7921..c3900bf 100644 --- a/lato/compositon.py +++ b/lato/compositon.py @@ -1,15 +1,15 @@ from collections.abc import Callable from functools import partial, reduce from operator import add, or_ -from typing import Any, Optional +from typing import Optional from mergedeep import Strategy, merge # type: ignore additive_merge = partial(merge, strategy=Strategy.TYPESAFE_ADDITIVE) -def compose(values: tuple[Any, ...], operator: Optional[Callable] = None): - values = tuple(v for v in values if v is not None) +def compose(compose_operator: Optional[Callable] = None, **kwargs): + values = tuple(value for module_name, value in kwargs.items() if value is not None) if len(values) == 0: return None @@ -18,8 +18,8 @@ def compose(values: tuple[Any, ...], operator: Optional[Callable] = None): if len(values) == 1: return first - if operator is not None: - operators = [operator] + if compose_operator is not None: + operators = [compose_operator] else: operators = [additive_merge, or_, add] diff --git a/lato/transaction_context.py b/lato/transaction_context.py index 74c6bb9..440453e 100644 --- a/lato/transaction_context.py +++ b/lato/transaction_context.py @@ -2,6 +2,7 @@ import logging from collections import OrderedDict from collections.abc import Awaitable, Callable, Iterator +from dataclasses import dataclass from functools import partial from typing import Any, Optional, Union @@ -16,13 +17,24 @@ log = logging.getLogger(__name__) + +@dataclass +class MessageHandler: + source: str + message: HandlerAlias + fn: Callable + + def __hash__(self): + return hash((self.source, self.fn)) + + 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]] +HandlersIterator = Callable[[HandlerAlias], Iterator[MessageHandler]] class TransactionContext: @@ -59,7 +71,7 @@ def __init__( dependency_provider or self.dependency_provider_factory(*args, **kwargs) ) self.resolved_kwargs: dict[str, Any] = {} - self.current_handler: Optional[Callable] = None + self.current_handler: Optional[MessageHandler] = None self._on_enter_transaction_context: Optional[ OnEnterTransactionContextCallback ] = None @@ -251,12 +263,11 @@ def execute(self, message: Message) -> tuple[Any, ...]: :raises: ValueError: If no handlers are found for the message. """ results = self.publish(message) - values = tuple(results.values()) - if len(values) == 0: - raise ValueError("No handlers found for message", values) + if len(results) == 0: + raise ValueError("No handlers found for message", message) - composed_result = self._compose_results(message, values) + composed_result = self._compose_results(message, results) return composed_result async def execute_async(self, message: Message) -> tuple[Any, ...]: @@ -267,23 +278,22 @@ async def execute_async(self, message: Message) -> tuple[Any, ...]: :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) + if len(results) == 0: + raise ValueError("No handlers found for message", message) - composed_result = self._compose_results(message, values) + composed_result = self._compose_results(message, results) return composed_result def emit( self, message: Union[str, Message], *args, **kwargs - ) -> dict[Callable, Any]: + ) -> dict[MessageHandler, Any]: # TODO: mark as obsolete return self.publish(message, *args, **kwargs) def publish( self, message: Union[str, Message], *args, **kwargs - ) -> dict[Callable, Any]: + ) -> dict[MessageHandler, Any]: """ Publish a message by calling all handlers for that message. @@ -302,13 +312,13 @@ def publish( self.set_dependency("message", message) # FIXME: push and pop current action instead of setting it self.current_handler = handler - result = self.call(handler, *args, **kwargs) + result = self.call(handler.fn, *args, **kwargs) all_results[handler] = result return all_results async def publish_async( self, message: Union[str, Message], *args, **kwargs - ) -> dict[Callable, Awaitable[Any]]: + ) -> dict[MessageHandler, Awaitable[Any]]: """ Asynchronously publish a message by calling all handlers for that message. @@ -330,7 +340,7 @@ async def publish_async( self.current_handler = ( None # FIXME: multiple handlers can be running asynchronously ) - result = await self.call_async(handler, *args, **kwargs) + result = await self.call_async(handler.fn, *args, **kwargs) all_results[handler] = result return all_results @@ -350,10 +360,15 @@ def set_dependencies(self, **kwargs): def __getitem__(self, item) -> Any: return self.get_dependency(item) - def _compose_results(self, message: Message, results: tuple[Any, ...]) -> Any: + def _compose_results( + self, message: Message, results: dict[MessageHandler, Any] + ) -> Any: alias = message.__class__ # TODO: expose alias as static field in Message class composer = self._composers.get(alias, compose) - return composer(results) + # TODO: there may be multiple values for one source, it this case we should raise an exception and + # instruct developer to implement a composer on a source level + kwargs = {k.source: v for k, v in results.items()} + return composer(**kwargs) @property def current_action(self) -> tuple[Message, Callable]: diff --git a/lato/utils.py b/lato/utils.py index 85b18cb..8021636 100644 --- a/lato/utils.py +++ b/lato/utils.py @@ -1,3 +1,4 @@ +import re from collections import OrderedDict from typing import TypeVar @@ -17,3 +18,14 @@ def add(self, item: T): def update(self, iterable): for item in iterable: self.add(item) + + +def string_to_kwarg_name(string): + # Remove invalid characters and replace them with underscores + valid_string = re.sub(r"[^a-zA-Z0-9_]", "_", string) + + # Ensure the name starts with a letter or underscore + if not valid_string[0].isalpha() and valid_string[0] != "_": + valid_string = "_" + valid_string + + return valid_string diff --git a/pyproject.toml b/pyproject.toml index e21c27a..f7fb6ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "lato" -version = "0.10.0" +version = "0.11.0" description = "Lato is a Python microframework designed for building modular monoliths and loosely coupled applications." authors = ["Przemysław Górecki "] readme = "README.md" diff --git a/tests/test_application.py b/tests/test_application.py index 63987c7..6fa6bd7 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -191,7 +191,7 @@ def handle_my_event(event: MyEvent): return f"handled {task.message}" task = MyEvent(message="foo") - assert app.emit(task) == {handle_my_event: "handled foo"} + assert tuple(app.publish(task).values()) == ("handled foo",) def test_create_transaction_context_callback(): diff --git a/tests/test_composition.py b/tests/test_composition.py index faceb9d..7a4b006 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -9,9 +9,9 @@ class SampleQuery(Command): def create_app(**kwargs): app = Application(name="App", **kwargs) - module_a = ApplicationModule("Module A") - module_b = ApplicationModule("Module B") - module_c = ApplicationModule("Module C") + module_a = ApplicationModule("ModuleA") + module_b = ApplicationModule("ModuleB") + module_c = ApplicationModule("ModuleC") app.include_submodule(module_a) app.include_submodule(module_b) @@ -34,9 +34,8 @@ def on_query_c(query: SampleQuery): def test_compose_nones(): - assert compose((None,)) is None - assert compose((None, None)) is None - assert compose((None, 1, None, 10)) == 11 + assert compose() is None + assert compose(a=None, b=None) is None def test_message_composition(): @@ -76,9 +75,9 @@ def test_compose_decorator(): app = create_app() @app.compose(SampleQuery) - def compose_sample_query(result): + def compose_sample_query(**kwargs): # this function will receive a tuple of results from all 3 handlers - result = compose(result) + result = compose(**kwargs) return result["a"] + result["b"] + result["c"] # act