Kurier — a unified messaging abstraction layer supporting Kafka, RabbitMQ, and Azure Service Bus
Project description
Kurier
Kurier is a unified messaging abstraction layer for Python, designed to simplify interactions with multiple message brokers. It supports Apache Kafka, RabbitMQ, Azure Service Bus, and an In-Memory transport, providing a consistent async API for publishing and consuming messages.
Inspired by .NET's MassTransit, Kurier aims to bring robust, type-safe, and easy-to-use messaging patterns to the Python ecosystem.
Features
- Multi-Line Support: Seamlessly switch between Kafka, RabbitMQ, Azure Service Bus, and In-Memory lines.
- Unified Async API: Use the same
await publish()and handler registration logic regardless of the underlying transport. - Typed Passengers: Built-in support for Pydantic models and dataclasses, ensuring message schema validation.
- Flexible Configuration: Configure lines using JSON, TOML, YAML, XML, or INI files, with
${VAR}environment variable interpolation. - Extensible: Register custom transport implementations without modifying Kurier's core.
- Line Registry: All lines are registered by name, enabling string-based lookups and enforcing global uniqueness.
- Distributed Tracing: Automatic
correlation_id(UUIDv7) injection for end-to-end message tracking. - Asyncio Native:
publish_async()andpublish_batch_async()are the primary APIs; sync wrappers are provided for convenience. - Qualified Routing: Automatically uses fully-qualified type names (
module.ClassName) to prevent handler collisions in large applications. - Python Compatibility: Supports Python 3.10 through 3.13.
Installation
pip install kurier-py
To install with specific transport support:
pip install "kurier-py[azure]"
pip install "kurier-py[kafka]"
pip install "kurier-py[rabbitmq]"
pip install "kurier-py[all]"
Quick Start
1. Define a Message
from pydantic import BaseModel
from datetime import datetime
class OrderCreated(BaseModel):
order_id: str
amount: float
created_at: datetime # Nested datetimes are supported recursively
2. Configure and Publish
import asyncio
from kurier.transports.inmemory.bus import InMemoryLine
from kurier.core.exceptions import KurierPublishError
async def main():
# Lines are registered by name for global lookup
line = InMemoryLine(name="orders_line")
message = OrderCreated(
order_id="12345",
amount=99.99,
created_at=datetime.now()
)
try:
# publish_async is the native async API
await line.publish_async(message)
except KurierPublishError as e:
print(f"Failed to publish: {e}")
await line.close()
asyncio.run(main())
3. Consume at a Station
Register handlers with @Station.add_handler. The handler receives two positional arguments:
- The typed message — Pydantic model, dataclass, or primitive. The type annotation determines routing.
- The full envelope dict — contains
correlation_id,object_type_name,message_payload, andmetadata.
Use *context in the signature to accept the envelope as an optional variadic argument:
from kurier import Station, BusType
# Handle messages from ANY in-memory line
@Station.add_handler(BusType.IN_MEMORY)
async def handle_order_created(message: OrderCreated, *context):
print(f"Received order: {message.order_id} for ${message.amount}")
# context[0] is the full envelope dict (if needed)
# Handle messages from a SPECIFIC line by name
@Station.add_handler("payments_kafka")
async def handle_payment_order(message: OrderCreated, *context):
print(f"Payment line received: {message.order_id}")
When a message arrives on a line, Kurier checks for handlers registered to that line's name first, then falls back to handlers registered to its BusType. Both are invoked if both match.
Handler Routing
By BusType (category-level)
Handlers registered with a BusType receive messages from all lines of that type:
@Station.add_handler(BusType.KAFKA)
def log_all_kafka_messages(message: OrderCreated, *context):
print(f"Kafka message: {message}")
By Line Name (instance-level)
Handlers registered with a string name only receive messages from that specific line:
@Station.add_handler("orders_kafka")
def handle_orders_only(message: OrderCreated, *context):
print(f"Orders line: {message}")
Multiple Handlers
Multiple handlers can be registered for the same message type. All matching handlers are invoked:
@Station.add_handler(BusType.IN_MEMORY)
def handler_a(message: OrderCreated, *context):
print("Handler A")
@Station.add_handler(BusType.IN_MEMORY)
def handler_b(message: OrderCreated, *context):
print("Handler B")
# Both handler_a and handler_b are called when an OrderCreated arrives
Sync and Async Handlers
Both synchronous and asynchronous handlers are supported. Sync handlers are automatically offloaded to a thread-pool executor so they never block the event loop:
@Station.add_handler(BusType.IN_MEMORY)
def sync_handler(message: OrderCreated, *context):
db.save(message) # runs in a thread-pool executor
@Station.add_handler(BusType.IN_MEMORY)
async def async_handler(message: OrderCreated, *context):
await external_api.notify(message) # runs on the event loop
Supported Message Types
Handlers support Pydantic models, dataclasses, and primitive types:
from dataclasses import dataclass
@dataclass
class SensorReading:
device_id: str
temperature: float
@Station.add_handler(BusType.KAFKA)
def handle_sensor(reading: SensorReading, *context):
print(f"Device {reading.device_id}: {reading.temperature}C")
@Station.add_handler(BusType.IN_MEMORY)
def handle_raw(message: str, *context):
print(f"Raw string: {message}")
Handler Errors
Handler exceptions propagate to the caller (the consumer loop / Station.dispatch). If a message payload cannot be converted to the handler's target type, a KurierDeserializationError is raised:
from kurier.core.exceptions import KurierDeserializationError
Batch Publishing
Efficiently send multiple messages in a single logical operation. Kurier handles chunking and assigns a batch-level correlation ID:
messages = [OrderCreated(order_id=str(i), amount=10.0 * i) for i in range(100)]
await line.publish_batch(messages, meta_data={"source": "bulk_import"}, batch_size=50)
CloudEvents Interop
Kurier now supports CloudEvents as an opt-in serializer. This is intended for cross-language interoperability where both sides agree on event type strings and broker topology explicitly.
Programmatic Usage
from kurier import Station
from kurier.transports.inmemory.bus import InMemoryLine
from kurier.utilities.serializers import CloudEventSerializer
line = InMemoryLine(
name="orders",
serializer=CloudEventSerializer(source="kurier://orders-service"),
)
@Station.add_event_handler("com.example.orders.created", "orders")
async def handle_order(message: OrderCreated, context):
print(context["event_type"])
await line.publish_async(
OrderCreated(order_id="12345", amount=99.99),
meta_data={"cloudevents_type": "com.example.orders.created"},
)
File-Based Configuration
[rabbitmq.orders]
name = "orders"
host = "localhost"
port = 5672
username = "guest"
password = "guest"
virtual_host = "/"
exchange_name = "contracts.orders"
routing_key = "submit-order"
serializer = "cloudevents"
cloudevents_source = "kurier://orders-service"
Topology Strategy
For interoperability, Kurier uses explicit broker configuration rather than trying to reproduce another framework's topology conventions automatically.
- RabbitMQ: set
exchange_name,exchange_type,routing_key, andqueue_nameexplicitly - Azure Service Bus: set
queue_nameortopic_name/subscription_nameexplicitly - Kafka: set
topic_nameexplicitly
This keeps topology ownership clear and avoids coupling Kurier to MassTransit-specific infrastructure rules.
Distributed Tracing
Every message in Kurier carries a correlation_id (UUIDv7). This ID is preserved across transport boundaries, allowing you to trace a message from its initial publish to its final execution in a handler.
Logs emitted by Hermes automatically include this ID at the DEBUG and TRACE levels.
Configuration
Environment Variable Interpolation
Configuration values can reference environment variables using ${VAR} or ${VAR:-default} syntax. If a variable is unset and no default is provided, a ValueError is raised at load time — this prevents silent misconfiguration:
[rabbitmq]
name = "main_line"
host = "${RABBIT_HOST:-localhost}"
port = 5672
username = "${RABBIT_USER}"
password = "${RABBIT_PASS}"
virtual_host = "/"
From File
Kurier supports multiple configuration formats (JSON, TOML, YAML, XML, INI/CFG). Here is an example config.toml:
[rabbitmq]
name = "main_line"
host = "localhost"
port = 5672
username = "guest"
password = "guest"
virtual_host = "/"
queue_name = "orders_queue"
[kafka]
name = "events_kafka"
bootstrap_servers = ["localhost:9092"]
topic_name = "events"
group_id = "kurier-consumer"
Load and initialize all lines from config:
from kurier.configurations.configurator import Configurator
lines = await Configurator.init_transports("config.toml")
# lines = {"main_line": <RabbitMQLine>, "events_kafka": <KafkaLine>}
Programmatic
Lines can also be created directly:
from kurier.transports.kafka.kafkaline import KafkaLine
from kurier.configurations.models.kafka import KafkaConfig
config = KafkaConfig(
name="orders_kafka",
bootstrap_servers=["localhost:9092"],
topic_name="orders",
group_id="order-service",
)
kafka_line = KafkaLine(config=config)
# Start consuming
KafkaLine.start_consumer("orders_kafka")
Line Registry
All lines are registered globally by name when created. Names must be unique — creating a line with a duplicate name raises ValueError:
from kurier.core.basekurier import BaseKurier
# Lookup a line by name
line = BaseKurier.from_registry("orders_kafka")
# List all lines of a specific type
all_kafka_lines = KafkaLine.get_all_items()
# Duplicate names are rejected
line_a = InMemoryLine() # name="in_memory_instance_line" -> OK
line_b = InMemoryLine() # -> ValueError: already registered
Logging
Kurier uses a built-in logging utility called Hermes. It provides a simple way to capture internal logs or route them to your preferred logging framework.
Basic Usage
from kurier.hermes import Hermes
# Redirect all INFO logs to stdout
Hermes.set_consumer("INFO", lambda msg: print(f"[MyLog] {msg}", end=""))
# Redirect ERROR logs to a custom handler
Hermes.set_consumer("ERROR", my_error_handler)
Integration with Python logging
import logging
from kurier.hermes import Hermes
logger = logging.getLogger("kurier")
Hermes.set_consumer("INFO", lambda msg: logger.info(msg.strip()))
Hermes.set_consumer("ERROR", lambda msg: logger.error(msg.strip()))
Hermes.set_consumer("DEBUG", lambda msg: logger.debug(msg.strip()))
Extending Kurier: Custom Transports
You can add support for other message brokers (e.g., Redis, SQS) without modifying Kurier's core code.
1. Define a Configuration Model
from pydantic import BaseModel
class RedisConfig(BaseModel):
name: str
host: str = "localhost"
port: int = 6379
channel: str
2. Implement the Transport Line
Subclass BaseKurier and implement the abstract methods. Call self._register() at the end of __init__ so that partially-initialized instances are never left in the registry on error.
from typing import Any
from kurier.core.basekurier import BaseKurier
from kurier.core.bus_types import BusType
from kurier.utilities.compatibility import override
class RedisLine(BaseKurier):
bus_type = BusType.IN_MEMORY # or define your own
def __init__(self, config: RedisConfig):
super().__init__(config.name)
self.config = config
# ... set up connections ...
self._register() # register only after all init succeeds
@override
def _publish_to_bus(self, json_message: str, meta_data: dict[str, Any]):
# Publish to Redis channel
pass
@override
def _run_consumer_loop(self):
# Subscribe and dispatch via Station
# loop.run_until_complete(Station.dispatch(self, envelope_str))
pass
@override
async def close(self):
pass
@classmethod
@override
def start_consumer(cls, name: str) -> bool:
pass
3. Register with Configurator (optional)
If you want file-based configuration support, register the transport class directly (no import-path strings needed):
from kurier.configurations.configurator import Configurator
Configurator.register_transport(
name="redis",
class_or_path=RedisLine, # direct class reference
config_class=RedisConfig,
)
For lazy-loaded transports with optional dependencies, you can pass a dotted import path string instead:
Configurator.register_transport(
name="redis",
class_or_path="my_project.transports.redis.RedisLine",
config_class=RedisConfig,
extra_requires="my_project[redis]",
)
Then in your config.toml:
[redis]
name = "cache_line"
host = "localhost"
port = 6379
channel = "notifications"
Development
Kurier uses uv for dependency management.
# Install dependencies including all transport extras and dev tools
uv sync --all-extras --dev
# Run the complete test suite (unit and serialization)
uv run pytest
# Run transport-specific mock integration tests
uv run pytest tests/integration/
# Lint and format
uv run ruff check src/ tests/
uv run ruff format src/ tests/
License
Distributed under the MIT License. See LICENSE for more information.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file kurier_py-0.1.0.tar.gz.
File metadata
- Download URL: kurier_py-0.1.0.tar.gz
- Upload date:
- Size: 122.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d24ddee67db48302960eaf0f5199b07f441ae0037fd0a1cba208b8bccbc2c720
|
|
| MD5 |
0d5f4412d78b4fc0bf148d3e69c25e6d
|
|
| BLAKE2b-256 |
854960f7f8c8f0923160238040e2698a45fa453cdf856293b3574b122ecdaee6
|
File details
Details for the file kurier_py-0.1.0-py3-none-any.whl.
File metadata
- Download URL: kurier_py-0.1.0-py3-none-any.whl
- Upload date:
- Size: 45.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.19
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
537b3f8515f06467e36c2bc19e6577d0185a2b81e3222d1f7310885818c9579f
|
|
| MD5 |
018b800755f01d7733bc3ebc49b99348
|
|
| BLAKE2b-256 |
1a9d4a2e52e960aa13a2dbfff62c3981a91590f345193186ac5006af7be1c44c
|