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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.31 (2026-06-07)

### Added (testing — Testcontainers integration)

Wave 4 (final) — the Spring Boot `@Testcontainers` / `@ServiceConnection` equivalent,
in `pyfly.testing.testcontainers`.

- **Container factories** (`postgres_container`, `mysql_container`, `redis_container`,
`mongodb_container`, `kafka_container`) wrapping `testcontainers` (new optional
extra `pyfly[testcontainers]`; lazily imported with a clear install hint).
- **`@ServiceConnection`-style auto-wiring**: `pyfly_config_for(container)` maps a
started container to the right pyfly config keys (Postgres/MySQL → async
`pyfly.data.relational.url`; Redis → `pyfly.cache.redis.url` + `pyfly.session.redis.url`;
Mongo → `pyfly.data.document.uri`; Kafka → `pyfly.eda.kafka.bootstrap-servers`), and
`pyfly_config(*containers)` builds a ready-to-use `Config`.
- **Graceful skip**: `is_docker_available()` + the `@requires_docker` decorator skip
integration tests cleanly when Docker (or the extra) is absent.

All exported from `pyfly.testing`.

---

## v26.06.30 (2026-06-07)

### Added (core — SpEL-lite expression evaluation)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.30-brightgreen" alt="Version: 26.06.30"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.31-brightgreen" alt="Version: 26.06.31"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
version = "26.6.30"
version = "26.6.31"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
Expand Down Expand Up @@ -50,6 +50,9 @@ data-relational = [
testing = [
"jsonpath-ng>=1.8.0",
]
testcontainers = [
"testcontainers>=4.0.0",
]
data-document = [
"motor>=3.7.1",
"beanie>=2.1.0",
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.30"
__version__ = "26.06.31"
20 changes: 20 additions & 0 deletions src/pyfly/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@
from pyfly.testing.fixtures import PyFlyTestCase
from pyfly.testing.mock import mock_bean
from pyfly.testing.slices import DataTest, ServiceTest, WebTest, get_test_slice
from pyfly.testing.testcontainers import (
is_docker_available,
kafka_container,
mongodb_container,
mysql_container,
postgres_container,
pyfly_config,
pyfly_config_for,
redis_container,
requires_docker,
)

__all__ = [
"DataTest",
Expand All @@ -31,5 +42,14 @@
"assert_no_events_published",
"create_test_container",
"get_test_slice",
"is_docker_available",
"kafka_container",
"mock_bean",
"mongodb_container",
"mysql_container",
"postgres_container",
"pyfly_config",
"pyfly_config_for",
"redis_container",
"requires_docker",
]
167 changes: 167 additions & 0 deletions src/pyfly/testing/testcontainers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Testcontainers — Docker-backed integration-test fixtures.

The Spring Boot ``@Testcontainers`` / ``@ServiceConnection`` equivalent: spin up a
real Postgres/MySQL/Redis/MongoDB/Kafka in Docker, then wire its connection details
straight into pyfly config keys via :func:`pyfly_config_for` / :func:`pyfly_config`.

Requires the extra and a running Docker daemon::

pip install 'pyfly[testcontainers]'

Guard integration tests so they skip cleanly where Docker is unavailable::

from pyfly.testing.testcontainers import postgres_container, pyfly_config, requires_docker

@requires_docker
def test_with_real_postgres():
with postgres_container() as pg:
config = pyfly_config(pg) # -> pyfly.data.relational.url = the container
...
"""

from __future__ import annotations

import importlib
from typing import TYPE_CHECKING, Any, TypeVar, cast

if TYPE_CHECKING:
from pyfly.core.config import Config

F = TypeVar("F")

_EXTRA_HINT = (
"Testcontainers support requires the extra and a running Docker daemon: pip install 'pyfly[testcontainers]'."
)
_NO_DOCKER = "Docker is not available (daemon down or the pyfly[testcontainers] extra is not installed)."


def is_docker_available() -> bool:
"""Whether a Docker daemon is reachable — integration tests should skip if not."""
try:
import docker # type: ignore[import-untyped]
except ModuleNotFoundError:
return False
try:
docker.from_env().ping()
return True
except Exception: # noqa: BLE001 - any connectivity failure means "not available"
return False


def requires_docker(func: F) -> F:
"""``pytest`` decorator that skips the test when Docker is unavailable."""
import pytest

return cast(F, pytest.mark.skipif(not is_docker_available(), reason=_NO_DOCKER)(func))


def _load(module: str, name: str) -> Any:
try:
loaded = importlib.import_module(module)
except ModuleNotFoundError as exc: # pragma: no cover - exercised only without the extra
raise RuntimeError(_EXTRA_HINT) from exc
return getattr(loaded, name)


def postgres_container(image: str = "postgres:16-alpine", **kwargs: Any) -> Any:
"""A ``testcontainers`` PostgresContainer (start via ``with``)."""
return _load("testcontainers.postgres", "PostgresContainer")(image, **kwargs)


def mysql_container(image: str = "mysql:8", **kwargs: Any) -> Any:
"""A ``testcontainers`` MySqlContainer."""
return _load("testcontainers.mysql", "MySqlContainer")(image, **kwargs)


def redis_container(image: str = "redis:7-alpine", **kwargs: Any) -> Any:
"""A ``testcontainers`` RedisContainer."""
return _load("testcontainers.redis", "RedisContainer")(image, **kwargs)


def mongodb_container(image: str = "mongo:7", **kwargs: Any) -> Any:
"""A ``testcontainers`` MongoDbContainer."""
return _load("testcontainers.mongodb", "MongoDbContainer")(image, **kwargs)


def kafka_container(image: str = "confluentinc/cp-kafka:7.6.0", **kwargs: Any) -> Any:
"""A ``testcontainers`` KafkaContainer."""
return _load("testcontainers.kafka", "KafkaContainer")(image, **kwargs)


def _async_db_url(url: str, replacements: dict[str, str]) -> str:
for sync_prefix, async_prefix in replacements.items():
if url.startswith(sync_prefix):
return async_prefix + url[len(sync_prefix) :]
return url


def pyfly_config_for(container: Any) -> dict[str, Any]:
"""Map a **started** testcontainer to pyfly config overrides (the ``@ServiceConnection``
equivalent). Returns flat dotted keys; raises ``ValueError`` for unmapped types.
"""
name = type(container).__name__
if "Postgres" in name:
url = _async_db_url(
container.get_connection_url(),
{
"postgresql+psycopg2://": "postgresql+asyncpg://",
"postgresql+psycopg://": "postgresql+asyncpg://",
"postgresql://": "postgresql+asyncpg://",
},
)
return {"pyfly.data.relational.url": url}
if "MySql" in name or "MySQL" in name:
url = _async_db_url(
container.get_connection_url(),
{"mysql+pymysql://": "mysql+aiomysql://", "mysql://": "mysql+aiomysql://"},
)
return {"pyfly.data.relational.url": url}
if "Redis" in name:
host = container.get_container_host_ip()
port = container.get_exposed_port(6379)
url = f"redis://{host}:{port}/0"
return {"pyfly.cache.redis.url": url, "pyfly.session.redis.url": url}
if "Mongo" in name:
return {"pyfly.data.document.uri": container.get_connection_url()}
if "Kafka" in name:
return {"pyfly.eda.kafka.bootstrap-servers": container.get_bootstrap_server()}
raise ValueError(f"No pyfly config mapping for container type {name!r}")


def _nest(flat: dict[str, Any]) -> dict[str, Any]:
"""Turn flat dotted keys into a nested dict (``{'a.b': 1}`` -> ``{'a': {'b': 1}}``)."""
root: dict[str, Any] = {}
for dotted, value in flat.items():
node = root
parts = dotted.split(".")
for part in parts[:-1]:
node = node.setdefault(part, {})
node[parts[-1]] = value
return root


def pyfly_config(*containers: Any, base: dict[str, Any] | None = None) -> Config:
"""Build a pyfly ``Config`` wiring every started container's connection details.

Merges :func:`pyfly_config_for` for each container (plus optional *base* flat
overrides) into a nested config — one-call setup for an integration ApplicationContext.
"""
from pyfly.core.config import Config

merged: dict[str, Any] = dict(base or {})
for container in containers:
merged.update(pyfly_config_for(container))
return Config(_nest(merged))
119 changes: 119 additions & 0 deletions tests/testing/test_testcontainers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Testcontainers integration (v26.06.31): @ServiceConnection-style config mapping,
graceful skip without Docker, and clear error without the extra.

The connection-mapping logic is tested with duck-typed fakes (no Docker needed); real
container startup is covered by the @requires_docker skip path.
"""

from __future__ import annotations

import pytest

from pyfly.testing.testcontainers import (
_load,
_nest,
is_docker_available,
pyfly_config,
pyfly_config_for,
requires_docker,
)


class _FakePostgres:
def get_connection_url(self) -> str:
return "postgresql+psycopg2://u:p@h:5432/test"


class _FakeMySql:
def get_connection_url(self) -> str:
return "mysql+pymysql://u:p@h:3306/test"


class _FakeRedis:
def get_container_host_ip(self) -> str:
return "127.0.0.1"

def get_exposed_port(self, port: int) -> int:
return 55001


class _FakeMongoDb:
def get_connection_url(self) -> str:
return "mongodb://h:27017"


class _FakeKafka:
def get_bootstrap_server(self) -> str:
return "127.0.0.1:55002"


def test_postgres_mapping_to_async_url() -> None:
assert pyfly_config_for(_FakePostgres()) == {"pyfly.data.relational.url": "postgresql+asyncpg://u:p@h:5432/test"}


def test_mysql_mapping_to_async_url() -> None:
assert pyfly_config_for(_FakeMySql()) == {"pyfly.data.relational.url": "mysql+aiomysql://u:p@h:3306/test"}


def test_redis_mapping_to_cache_and_session() -> None:
cfg = pyfly_config_for(_FakeRedis())
assert cfg["pyfly.cache.redis.url"] == "redis://127.0.0.1:55001/0"
assert cfg["pyfly.session.redis.url"] == "redis://127.0.0.1:55001/0"


def test_mongo_and_kafka_mappings() -> None:
assert pyfly_config_for(_FakeMongoDb()) == {"pyfly.data.document.uri": "mongodb://h:27017"}
assert pyfly_config_for(_FakeKafka()) == {"pyfly.eda.kafka.bootstrap-servers": "127.0.0.1:55002"}


def test_unmapped_container_raises() -> None:
class Other:
pass

with pytest.raises(ValueError):
pyfly_config_for(Other())


def test_pyfly_config_builds_nested_config() -> None:
cfg = pyfly_config(_FakePostgres(), _FakeRedis())
assert cfg.get("pyfly.data.relational.url") == "postgresql+asyncpg://u:p@h:5432/test"
assert cfg.get("pyfly.cache.redis.url") == "redis://127.0.0.1:55001/0"


def test_nest_flat_keys() -> None:
assert _nest({"a.b.c": 1, "a.b.d": 2, "x": 3}) == {"a": {"b": {"c": 1, "d": 2}}, "x": 3}


def test_is_docker_available_returns_bool() -> None:
assert isinstance(is_docker_available(), bool)


def test_load_missing_module_raises_clear_install_hint() -> None:
# _load surfaces the extra/install hint when a backing module is unavailable —
# independent of whether the testcontainers extra happens to be installed.
with pytest.raises(RuntimeError, match=r"pyfly\[testcontainers\]"):
_load("testcontainers._pyfly_nonexistent_module", "Nope")


def test_requires_docker_applies_skipif_mark() -> None:
# Robust across environments (Docker present or not): the decorator must attach a
# skipif mark so the test skips wherever Docker is unavailable.
@requires_docker
def dummy() -> None:
pass

marks = getattr(dummy, "pytestmark", [])
assert any(getattr(mark, "name", "") == "skipif" for mark in marks)
Loading