Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
111 changes: 111 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
7 changes: 6 additions & 1 deletion docs/key_concepts/cq_composition.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion lato/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -19,6 +24,9 @@
"Command",
"Query",
"Event",
"DuplicateHandlerError",
"HandlerNotFoundError",
"UnknownDependencyError",
"as_type",
"compose",
]
Expand Down
13 changes: 7 additions & 6 deletions lato/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion lato/application_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 1 addition & 4 deletions lato/dependency_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions lato/exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""

...
9 changes: 5 additions & 4 deletions lato/transaction_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <przemyslaw.gorecki@gmail.com>"]
readme = "README.md"
Expand Down
Loading