Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 3 additions & 3 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.10", "3.11", "3.12" ]
python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ]

steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
Expand All @@ -28,6 +28,6 @@ jobs:
- name: Build package
run: python -m build
- name: Publish package
if: success() && github.event_name == 'release' && matrix.python-version == '3.12'
if: success() && github.event_name == 'release' && matrix.python-version == '3.14'
run: |
twine upload dist/* --username __token__ --password ${{ secrets.PYPI_API_TOKEN }}
36 changes: 32 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,26 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.experimental || false }}
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
include:
- python-version: "3.9"
pyright-version: "3.9"
- python-version: "3.10"
pyright-version: "3.10"
- python-version: "3.11"
pyright-version: "3.11"
- python-version: "3.12"
pyright-version: "3.12"
- python-version: "3.13"
pyright-version: "3.13"
- python-version: "3.14"
pyright-version: "3.14"
- python-version: "3.15-dev"
pyright-version: "3.14"
experimental: true

steps:
- uses: actions/checkout@v4
Expand All @@ -23,6 +39,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true

- name: Install dependencies
run: |
Expand Down Expand Up @@ -61,20 +78,30 @@ jobs:
while IFS= read -r f; do [ -f "$f" ] && echo "$f"; done < changed.txt | xargs -r ruff format --check --config ruff.toml

- name: Run pyright
if: ${{ matrix.python-version != '3.15-dev' }}
run: |
pyright --pythonversion ${{ matrix.python-version }} src tests examples
pyright --pythonversion ${{ matrix.pyright-version }} src

- name: Check minimum Python version (vermin)
run: |
vermin --target=3.10- --violations --eval-annotations --backport typing_extensions --exclude=venv --exclude=build --exclude=.git --exclude=.venv src examples tests
vermin --target=3.9- --violations --eval-annotations --backport typing_extensions --exclude=venv --exclude=build --exclude=.git --exclude=.venv src examples tests

test:
name: test (py ${{ matrix.python-version }})
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.experimental || false }}
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
include:
- python-version: "3.9"
- python-version: "3.10"
- python-version: "3.11"
- python-version: "3.12"
- python-version: "3.13"
- python-version: "3.14"
- python-version: "3.15-dev"
experimental: true

steps:
- uses: actions/checkout@v4
Expand All @@ -83,6 +110,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true

- name: Install dependencies
run: |
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ repos:
hooks:
- id: pytest-unit
name: unit tests
entry: pytest -c ./tests/pytest-config.ini ./tests/unit
entry: env PYTHONPATH=src ./venv/bin/python -m pytest -c ./tests/pytest-config.ini ./tests/unit
language: system
types: [python]
pass_filenames: false
always_run: true
- id: pytest-integration
name: integration tests
entry: pytest -c ./tests/pytest-config.ini ./tests/integration
entry: env PYTHONPATH=src ./venv/bin/python -m pytest -c ./tests/pytest-config.ini ./tests/integration
language: system
types: [python]
pass_filenames: false
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<h1>Python CQRS</h1>
<p><strong>Event-Driven Architecture Framework for Distributed Systems</strong></p>
<p>
<strong>Python 3.10+</strong> · Full documentation: <a href="https://mkdocs.python-cqrs.dev/">mkdocs.python-cqrs.dev</a>
<strong>Python 3.9+</strong> · Full documentation: <a href="https://mkdocs.python-cqrs.dev/">mkdocs.python-cqrs.dev</a>
</p>
<p>
<a href="https://pypi.org/project/python-cqrs/">
Expand Down Expand Up @@ -88,7 +88,7 @@ project ([documentation](https://akhundmurad.github.io/diator/)) with several en

## Installation

**Python 3.10+** is required.
**Python 3.9+** is required. CI runs on Python **3.9-3.14** and also tracks **3.15-dev** compatibility.

```bash
pip install python-cqrs
Expand Down
1 change: 0 additions & 1 deletion docker-compose-dev.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: '3'
services:
mysql_dev:
image: mysql:8.3.0
Expand Down
10 changes: 5 additions & 5 deletions examples/cor_mermaid.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class PaymentResult(cqrs.Response):
"""Payment processing result."""

success: bool
transaction_id: str | None = None
transaction_id: typing.Optional[str] = None
message: str = ""


Expand Down Expand Up @@ -109,7 +109,7 @@ def __init__(self) -> None:
def events(self) -> typing.List[Event]:
return self._events.copy()

async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
async def handle(self, request: ProcessPaymentCommand) -> typing.Optional[PaymentResult]:
"""Process credit card payment."""
if request.payment_method == "credit_card":
transaction_id = f"cc_{request.user_id}_{int(request.amount * 100)}"
Expand Down Expand Up @@ -143,7 +143,7 @@ def __init__(self) -> None:
def events(self) -> typing.List[Event]:
return self._events.copy()

async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
async def handle(self, request: ProcessPaymentCommand) -> typing.Optional[PaymentResult]:
"""Process PayPal payment."""
if request.payment_method == "paypal":
transaction_id = f"pp_{request.user_id}_{int(request.amount * 100)}"
Expand Down Expand Up @@ -177,7 +177,7 @@ def __init__(self) -> None:
def events(self) -> typing.List[Event]:
return self._events.copy()

async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
async def handle(self, request: ProcessPaymentCommand) -> typing.Optional[PaymentResult]:
"""Process bank transfer payment."""
if request.payment_method == "bank_transfer":
transaction_id = f"bt_{request.user_id}_{int(request.amount * 100)}"
Expand Down Expand Up @@ -207,7 +207,7 @@ class DefaultPaymentHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResu
def events(self) -> typing.List[Event]:
return []

async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
async def handle(self, request: ProcessPaymentCommand) -> typing.Optional[PaymentResult]:
"""Handle unsupported payment methods."""
return PaymentResult(
success=False,
Expand Down
7 changes: 4 additions & 3 deletions examples/cor_request_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@

import asyncio
import logging
import typing

import di
from di import dependent
Expand Down Expand Up @@ -96,7 +97,7 @@ class SourceAHandler(CORRequestHandler[FetchDataCommand, FetchDataResult]):
def events(self) -> list[cqrs.Event]:
return []

async def handle(self, request: FetchDataCommand) -> FetchDataResult | None:
async def handle(self, request: FetchDataCommand) -> typing.Optional[FetchDataResult]:
if request.source == "a":
logger.info("COR chain: SourceAHandler handled source=a")
HANDLER_SOURCE.append("chain")
Expand All @@ -109,7 +110,7 @@ class SourceBHandler(CORRequestHandler[FetchDataCommand, FetchDataResult]):
def events(self) -> list[cqrs.Event]:
return []

async def handle(self, request: FetchDataCommand) -> FetchDataResult | None:
async def handle(self, request: FetchDataCommand) -> typing.Optional[FetchDataResult]:
if request.source == "b":
logger.info("COR chain: SourceBHandler handled source=b")
HANDLER_SOURCE.append("chain")
Expand All @@ -124,7 +125,7 @@ class DefaultChainHandler(CORRequestHandler[FetchDataCommand, FetchDataResult]):
def events(self) -> list[cqrs.Event]:
return []

async def handle(self, request: FetchDataCommand) -> FetchDataResult | None:
async def handle(self, request: FetchDataCommand) -> typing.Optional[FetchDataResult]:
if request.source == "error":
logger.info("COR chain: DefaultChainHandler raising ConnectionError for source=error")
raise ConnectionError("Downstream service unavailable")
Expand Down
10 changes: 5 additions & 5 deletions examples/cor_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class ProcessPaymentCommand(cqrs.Request):

class PaymentResult(cqrs.Response):
success: bool
transaction_id: str | None = None
transaction_id: typing.Optional[str] = None
message: str = ""


Expand All @@ -101,7 +101,7 @@ def __init__(self) -> None:
super().__init__()
self._events: typing.List[cqrs.Event] = []

async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
async def handle(self, request: ProcessPaymentCommand) -> typing.Optional[PaymentResult]:
if request.payment_method == "credit_card":
transaction_id = f"cc_{request.user_id}_{int(request.amount * 100)}"
TRANSACTIONS["credit_card"].append(transaction_id)
Expand Down Expand Up @@ -134,7 +134,7 @@ def __init__(self) -> None:
super().__init__()
self._events: typing.List[cqrs.Event] = []

async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
async def handle(self, request: ProcessPaymentCommand) -> typing.Optional[PaymentResult]:
if request.payment_method == "paypal":
transaction_id = f"pp_{request.user_id}_{int(request.amount * 100)}"
TRANSACTIONS["paypal"].append(transaction_id)
Expand Down Expand Up @@ -167,7 +167,7 @@ def __init__(self) -> None:
super().__init__()
self._events: typing.List[cqrs.Event] = []

async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
async def handle(self, request: ProcessPaymentCommand) -> typing.Optional[PaymentResult]:
if request.payment_method == "bank_transfer":
transaction_id = f"bt_{request.user_id}_{int(request.amount * 100)}"
TRANSACTIONS["bank_transfer"].append(transaction_id)
Expand Down Expand Up @@ -198,7 +198,7 @@ class DefaultPaymentHandler(CORRequestHandler[ProcessPaymentCommand, PaymentResu
def events(self) -> typing.List[cqrs.Event]:
return []

async def handle(self, request: ProcessPaymentCommand) -> PaymentResult | None:
async def handle(self, request: ProcessPaymentCommand) -> typing.Optional[PaymentResult]:
# Default handler always handles the request (end of chain)
print(
f"Default: Unsupported payment method '{request.payment_method}' for user {request.user_id}",
Expand Down
4 changes: 2 additions & 2 deletions examples/kafka_event_consuming.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async def empty_message_decoder(
[kafka.KafkaMessage],
typing.Awaitable[types.DecodedMessage],
],
) -> types.DecodedMessage | None:
) -> typing.Optional[types.DecodedMessage]:
"""
Decode a kafka message and return it if it is not empty.
"""
Expand Down Expand Up @@ -158,7 +158,7 @@ def mediator_factory() -> cqrs.EventMediator:
decoder=empty_message_decoder,
)
async def hello_world_event_handler(
body: cqrs.NotificationEvent[HelloWorldPayload] | deserializers.DeserializeJsonError | None,
body: typing.Union[cqrs.NotificationEvent[HelloWorldPayload], typing.Optional[deserializers.DeserializeJsonError]],
msg: kafka.KafkaMessage,
mediator: cqrs.EventMediator = faststream.Depends(mediator_factory),
):
Expand Down
7 changes: 4 additions & 3 deletions examples/saga.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
import asyncio
import dataclasses
import logging
import typing
import uuid

import di
Expand Down Expand Up @@ -119,9 +120,9 @@ class OrderContext(SagaContext):
shipping_address: str

# These fields are populated by steps during execution
inventory_reservation_id: str | None = None
payment_id: str | None = None
shipment_id: str | None = None
inventory_reservation_id: typing.Optional[str] = None
payment_id: typing.Optional[str] = None
shipment_id: typing.Optional[str] = None


# ============================================================================
Expand Down
3 changes: 2 additions & 1 deletion examples/saga_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
import asyncio
import dataclasses
import logging
import typing
import uuid

import di
Expand Down Expand Up @@ -104,7 +105,7 @@ class OrderContext(SagaContext):
amount: float

# This field is populated by step during execution
reservation_id: str | None = None
reservation_id: typing.Optional[str] = None


# ============================================================================
Expand Down
8 changes: 4 additions & 4 deletions examples/saga_fastapi_sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@ class OrderContext(SagaContext):
total_amount: float
shipping_address: str

inventory_reservation_id: str | None = None
payment_id: str | None = None
shipment_id: str | None = None
inventory_reservation_id: typing.Optional[str] = None
payment_id: typing.Optional[str] = None
shipment_id: typing.Optional[str] = None


class ProcessOrderRequest(pydantic.BaseModel):
Expand Down Expand Up @@ -448,7 +448,7 @@ def mediator_factory() -> cqrs.SagaMediator:
)


def serialize_response(response: Response | None) -> dict[str, typing.Any]:
def serialize_response(response: typing.Optional[Response]) -> dict[str, typing.Any]:
if response is None:
return {}
return response.to_dict()
Expand Down
6 changes: 3 additions & 3 deletions examples/saga_mermaid.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ class OrderContext(SagaContext):
total_amount: float
shipping_address: str

inventory_reservation_id: str | None = None
payment_id: str | None = None
shipment_id: str | None = None
inventory_reservation_id: typing.Optional[str] = None
payment_id: typing.Optional[str] = None
shipment_id: typing.Optional[str] = None


class ReserveInventoryResponse(Response):
Expand Down
6 changes: 3 additions & 3 deletions examples/saga_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,9 @@ class OrderContext(SagaContext):
shipping_address: str

# These fields are populated by steps during execution
inventory_reservation_id: str | None = None
payment_id: str | None = None
shipment_id: str | None = None
inventory_reservation_id: typing.Optional[str] = None
payment_id: typing.Optional[str] = None
shipment_id: typing.Optional[str] = None


# ============================================================================
Expand Down
8 changes: 4 additions & 4 deletions examples/saga_recovery_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,9 @@ class OrderContext(SagaContext):
total_amount: float
shipping_address: str

inventory_reservation_id: str | None = None
payment_id: str | None = None
shipment_id: str | None = None
inventory_reservation_id: typing.Optional[str] = None
payment_id: typing.Optional[str] = None
shipment_id: typing.Optional[str] = None


# ============================================================================
Expand Down Expand Up @@ -536,7 +536,7 @@ async def recovery_loop(
storage: ISagaStorage,
*,
interval_seconds: float = RECOVERY_INTERVAL_SECONDS,
max_iterations: int | None = None,
max_iterations: typing.Optional[int] = None,
) -> None:
"""
Run the recovery scheduler loop.
Expand Down
Loading
Loading