diff --git a/CHANGELOG.md b/CHANGELOG.md index cb4dac0..d0b66d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## [0.13.0] - 2025-02-20 + +### Breaking Changes + +- Registering multiple handlers for the same `Command` or `Query` in a single module now raises `DuplicateHandlerError` (fixes #8) + +### Added + +- `lato.exceptions` module with all custom exceptions +- `DuplicateHandlerError(TypeError)` — raised on duplicate command/query handler registration +- `HandlerNotFoundError(LookupError)` — raised when no handler is found for an alias or message +- `UnknownDependencyError(KeyError)` — moved from `dependency_provider` to `exceptions` + ## [0.12.4] - 2025-02-20 - Moved `pytest-asyncio` from runtime to dev dependencies (fixes #9, #10) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3c07284 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +# Lato - AI Development Guide + +## Project Overview + +Lato is a Python microframework for building modular monoliths and loosely coupled applications. It implements CQRS patterns with Commands, Queries, and Events, dependency injection, and transaction contexts. + +- **Author**: Przemyslaw Gorecki +- **License**: MIT +- **Docs**: https://lato.readthedocs.io +- **PyPI**: https://pypi.org/project/lato/ + +## Architecture + +### Core Concepts + +- **Application** (`lato/application.py`): Top-level entry point, extends `ApplicationModule` +- **ApplicationModule** (`lato/application_module.py`): Registers handlers, supports nested submodules +- **TransactionContext** (`lato/transaction_context.py`): Scoped context for handler execution with middleware support +- **DependencyProvider** (`lato/dependency_provider.py`): Automatic dependency injection by name or type +- **Messages** (`lato/message.py`): `Command`, `Query`, `Event` base classes (all extend `Message`) +- **Exceptions** (`lato/exceptions.py`): All custom exceptions in one place + +### CQRS Rules + +- **Command/Query**: One handler per message type per module. Registering a second raises `DuplicateHandlerError`. Multiple modules can each have one handler for the same Command/Query (results get composed). +- **Event**: Multiple handlers allowed per module (pub/sub pattern). + +### Handler Dispatch + +- `app.call(func_or_alias)` — invoke a single function or alias +- `app.execute(command)` — execute all handlers for a Command/Query, compose results +- `app.publish(event)` — publish to all Event handlers, return dict of results +- All three have `_async` variants + +## Development + +### Setup + +```bash +poetry install --without examples +``` + +### Running Tests + +```bash +poetry run python -m pytest -p no:sugar -q tests/ +``` + +### Running Mypy + +```bash +poetry run mypy lato +``` + +### Running Doctests + +```bash +poetry run pytest --doctest-modules lato +``` + +### Building Docs + +```bash +poetry run sphinx-build -b html docs docs/_build +``` + +### Pre-commit Hooks + +The project uses pre-commit hooks: `isort`, `black`, `autoflake`, and type hint upgrades. If a commit fails due to hooks, re-stage the auto-fixed files and commit again (new commit, not amend). + +## CI/CD + +### Tests Workflow (`.github/workflows/tests.yml`) + +- Triggers on: push to `main`, pull requests +- Matrix: Ubuntu/MacOS/Windows x Python 3.9/3.10/3.11/3.12 +- Python 3.9 jobs use Poetry 1.8.5 (Poetry 2.x requires Python 3.10+) +- Python 3.10+ jobs use latest Poetry 2.x +- Lock file check: `poetry lock --check` (Poetry 1.x) or `poetry check --lock` (Poetry 2.x) + +### Release Workflow (`.github/workflows/release.yml`) + +- Triggers on: tag push matching `v*.*.*` +- Waits for Tests workflow to pass before publishing +- Uses Python 3.12 for build/publish +- Publishes to PyPI via `poetry publish` + +## Release Process + +1. Create a release branch (e.g. `release/0.13.0`) +2. Bump version in `pyproject.toml`, `lato/__init__.py`, and `CHANGELOG.md` +3. Update `poetry.lock` if dependencies changed (`poetry lock`) +4. Push branch, open PR, wait for tests to pass +5. Squash merge the PR +6. Pull main, tag (`git tag v0.13.0`), push tag +7. The tag push triggers the Release workflow + +## Version Tracking + +Version must be kept in sync in three places: +- `pyproject.toml` (`version = "..."`) +- `lato/__init__.py` (`__version__ = "..."`) +- `CHANGELOG.md` (new entry at top) + +## Code Style + +- Imports sorted by `isort`, formatted by `black` +- Type hints used throughout +- Custom exceptions inherit from the closest built-in (`TypeError`, `LookupError`, `KeyError`) +- All custom exceptions live in `lato/exceptions.py` and are exported from `lato/__init__` +- Docstrings use Sphinx `:param:`, `:return:`, `:raises:` format diff --git a/docs/api.rst b/docs/api.rst index e67720c..99d67c5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -21,3 +21,15 @@ API Reference .. autoclass:: lato.TransactionContext :members: :special-members: __init__ + +Exceptions +---------- + +.. autoexception:: lato.DuplicateHandlerError + :show-inheritance: + +.. autoexception:: lato.HandlerNotFoundError + :show-inheritance: + +.. autoexception:: lato.UnknownDependencyError + :show-inheritance: diff --git a/docs/key_concepts/cq_composition.rst b/docs/key_concepts/cq_composition.rst index c4df428..60073e2 100644 --- a/docs/key_concepts/cq_composition.rst +++ b/docs/key_concepts/cq_composition.rst @@ -3,10 +3,15 @@ Message Composition and Decomposition ===================================== -If there are multiple command handlers (i.e. in different modules) for the same Command, all handlers for that +If there are multiple command handlers 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). +.. note:: + Each module can only register **one** handler per Command or Query. Attempting to register a second handler + for the same Command or Query within the same module will raise :class:`~lato.DuplicateHandlerError`. + Events are not subject to this restriction. + .. literalinclude:: ../../examples/example5.py By default, handler responses of type ``dict`` will be recursively merged into a single dict, and responses of type diff --git a/lato/__init__.py b/lato/__init__.py index de3a0d3..f809349 100644 --- a/lato/__init__.py +++ b/lato/__init__.py @@ -6,10 +6,15 @@ from .application_module import ApplicationModule from .compositon import compose from .dependency_provider import BasicDependencyProvider, DependencyProvider, as_type +from .exceptions import ( + DuplicateHandlerError, + HandlerNotFoundError, + UnknownDependencyError, +) from .message import Command, Event, Query from .transaction_context import TransactionContext -__version__ = "0.12.4" +__version__ = "0.13.0" __all__ = [ "Application", "ApplicationModule", @@ -19,6 +24,9 @@ "Command", "Query", "Event", + "DuplicateHandlerError", + "HandlerNotFoundError", + "UnknownDependencyError", "as_type", "compose", ] diff --git a/lato/application.py b/lato/application.py index f5cc3cd..606c721 100644 --- a/lato/application.py +++ b/lato/application.py @@ -4,6 +4,7 @@ from lato.application_module import ApplicationModule from lato.dependency_provider import BasicDependencyProvider, DependencyProvider +from lato.exceptions import HandlerNotFoundError from lato.message import Event, Message from lato.transaction_context import ( ComposerFunction, @@ -81,14 +82,14 @@ def call(self, func: Union[Callable[..., Any], str], *args, **kwargs) -> Any: :return: The result of the invoked function. - :raises ValueError: If an alias is provided, but no corresponding handler is found. + :raises HandlerNotFoundError: If an alias is provided, but no corresponding handler is found. """ if isinstance(func, str): try: message_handler = next(self.iterate_handlers_for(alias=func)) func = message_handler.fn except StopIteration: - raise ValueError(f"Handler not found", func) + raise HandlerNotFoundError(f"Handler not found: {func}") with self.transaction_context() as ctx: result = ctx.call(func, *args, **kwargs) @@ -108,14 +109,14 @@ async def call_async( :return: The result of the invoked function. - :raises ValueError: If an alias is provided, but no corresponding handler is found. + :raises HandlerNotFoundError: If an alias is provided, but no corresponding handler is found. """ if isinstance(func, str): try: message_handler = next(self.iterate_handlers_for(alias=func)) func = message_handler.fn except StopIteration: - raise ValueError(f"Handler not found", func) + raise HandlerNotFoundError(f"Handler not found: {func}") async with self.transaction_context() as ctx: result = await ctx.call_async(func, *args, **kwargs) @@ -130,7 +131,7 @@ def execute(self, message: Message) -> Any: :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. + :raises HandlerNotFoundError: If no handlers are found for the message. """ with self.transaction_context() as ctx: result = ctx.execute(message) @@ -145,7 +146,7 @@ async def execute_async(self, message: Message) -> Any: :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. + :raises HandlerNotFoundError: If no handlers are found for the message. """ async with self.transaction_context() as ctx: result = await ctx.execute_async(message) diff --git a/lato/application_module.py b/lato/application_module.py index bb23be5..c569951 100644 --- a/lato/application_module.py +++ b/lato/application_module.py @@ -2,7 +2,8 @@ from collections import defaultdict from collections.abc import Callable -from lato.message import Message +from lato.exceptions import DuplicateHandlerError +from lato.message import Command, Message, Query from lato.transaction_context import MessageHandler from lato.types import HandlerAlias from lato.utils import OrderedSet, string_to_kwarg_name @@ -92,6 +93,15 @@ def decorator(func): """ Decorator for registering tasks by name """ + if ( + is_message_type + and issubclass(alias, (Command, Query)) + and len(self._handlers[alias]) > 0 + ): + raise DuplicateHandlerError( + f"A handler for {alias.__name__} is already registered in module '{self.name}'. " + f"Commands and queries can only have one handler per module." + ) self._handlers[alias].add(func) return func diff --git a/lato/dependency_provider.py b/lato/dependency_provider.py index 579640b..acf3590 100644 --- a/lato/dependency_provider.py +++ b/lato/dependency_provider.py @@ -3,6 +3,7 @@ from collections.abc import Callable from typing import Any +from lato.exceptions import UnknownDependencyError from lato.types import DependencyIdentifier from lato.utils import OrderedDict @@ -17,10 +18,6 @@ def as_type(obj: Any, cls: type) -> TypedDependency: return TypedDependency(obj, cls) -class UnknownDependencyError(KeyError): - ... - - def get_function_parameters(func) -> OrderedDict: """ Retrieve the function's parameters and their annotations. diff --git a/lato/exceptions.py b/lato/exceptions.py new file mode 100644 index 0000000..549b70e --- /dev/null +++ b/lato/exceptions.py @@ -0,0 +1,16 @@ +class DuplicateHandlerError(TypeError): + """Raised when a Command or Query handler is registered more than once in the same module.""" + + ... + + +class HandlerNotFoundError(LookupError): + """Raised when no handler is found for a given alias or message.""" + + ... + + +class UnknownDependencyError(KeyError): + """Raised when a dependency cannot be resolved by the dependency provider.""" + + ... diff --git a/lato/transaction_context.py b/lato/transaction_context.py index 6868f30..417ade9 100644 --- a/lato/transaction_context.py +++ b/lato/transaction_context.py @@ -12,6 +12,7 @@ DependencyProvider, as_type, ) +from lato.exceptions import HandlerNotFoundError from lato.message import Message from lato.types import HandlerAlias from lato.utils import maybe_await @@ -298,12 +299,12 @@ def execute(self, message: Message) -> tuple[Any, ...]: :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. + :raises HandlerNotFoundError: If no handlers are found for the message. """ results = self.publish(message) if len(results) == 0: - raise ValueError("No handlers found for message", message) + raise HandlerNotFoundError(f"No handlers found for message: {message}") composed_result = self._compose_results(message, results) return composed_result @@ -313,12 +314,12 @@ async def execute_async(self, message: Message) -> tuple[Any, ...]: :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. + :raises HandlerNotFoundError: If no handlers are found for the message. """ results = await self.publish_async(message) if len(results) == 0: - raise ValueError("No handlers found for message", message) + raise HandlerNotFoundError(f"No handlers found for message: {message}") composed_result = self._compose_results(message, results) return composed_result diff --git a/pyproject.toml b/pyproject.toml index 55b7929..45ac13b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "lato" -version = "0.12.4" +version = "0.13.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 6fa6bd7..fc27c86 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,6 +1,6 @@ import pytest -from lato import Application, Command, Event, TransactionContext +from lato import Application, Command, DuplicateHandlerError, Event, TransactionContext class FooService: @@ -194,6 +194,44 @@ def handle_my_event(event: MyEvent): assert tuple(app.publish(task).values()) == ("handled foo",) +def test_command_handler_cannot_be_registered_twice(): + from lato import ApplicationModule + + module = ApplicationModule("test") + + class MyCommand(Command): + pass + + @module.handler(MyCommand) + def handler_a(command: MyCommand): + pass + + with pytest.raises(DuplicateHandlerError, match="already registered"): + + @module.handler(MyCommand) + def handler_b(command: MyCommand): + pass + + +def test_event_handler_can_be_registered_multiple_times(): + from lato import ApplicationModule + + module = ApplicationModule("test") + + class MyEvent(Event): + pass + + @module.handler(MyEvent) + def handler_a(event: MyEvent): + pass + + @module.handler(MyEvent) + def handler_b(event: MyEvent): + pass + + assert len(module.get_handlers_for(MyEvent)) == 2 + + def test_create_transaction_context_callback(): from lato import Application, TransactionContext diff --git a/tests/test_transaction_context.py b/tests/test_transaction_context.py index 9afeff1..f1dec0a 100644 --- a/tests/test_transaction_context.py +++ b/tests/test_transaction_context.py @@ -43,7 +43,9 @@ def test_call_with_kwarg_and_dependency(): def test_call_without_handlers(): + from lato import HandlerNotFoundError + ctx = TransactionContext() command = Command() - with pytest.raises(ValueError): + with pytest.raises(HandlerNotFoundError): ctx.execute(command)