diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f1664..e6ecac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 3ee5eb0..e0d9825 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.30 + Version: 26.06.31 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index a4653eb..f62cb1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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", diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 1911086..1d50976 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.30" +__version__ = "26.06.31" diff --git a/src/pyfly/testing/__init__.py b/src/pyfly/testing/__init__.py index 4d05b50..75b8fee 100644 --- a/src/pyfly/testing/__init__.py +++ b/src/pyfly/testing/__init__.py @@ -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", @@ -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", ] diff --git a/src/pyfly/testing/testcontainers.py b/src/pyfly/testing/testcontainers.py new file mode 100644 index 0000000..725ed01 --- /dev/null +++ b/src/pyfly/testing/testcontainers.py @@ -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)) diff --git a/tests/testing/test_testcontainers.py b/tests/testing/test_testcontainers.py new file mode 100644 index 0000000..808d85c --- /dev/null +++ b/tests/testing/test_testcontainers.py @@ -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) diff --git a/uv.lock b/uv.lock index 2aa00a0..df999b5 100644 --- a/uv.lock +++ b/uv.lock @@ -720,6 +720,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "fastapi" version = "0.136.1" @@ -1967,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.30" +version = "26.6.31" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -2074,6 +2088,9 @@ security = [ shell = [ { name = "click" }, ] +testcontainers = [ + { name = "testcontainers" }, +] testing = [ { name = "jsonpath-ng" }, ] @@ -2149,11 +2166,12 @@ requires-dist = [ { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'data-relational'", specifier = ">=2.0.49" }, { name = "starlette", marker = "extra == 'web'", specifier = ">=1.0.0" }, { name = "structlog", marker = "extra == 'observability'", specifier = ">=25.5.0" }, + { name = "testcontainers", marker = "extra == 'testcontainers'", specifier = ">=4.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.46.0" }, { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'web-fast'", specifier = ">=0.22.1" }, { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'web-fastapi'", specifier = ">=0.22.1" }, ] -provides-extras = ["web", "data-relational", "testing", "data-document", "postgresql", "eda", "fastapi", "granian", "hypercorn", "kafka", "rabbitmq", "redis", "cache", "client", "observability", "scheduling", "pii", "security", "cli", "shell", "web-fast", "web-fastapi", "full"] +provides-extras = ["web", "data-relational", "testing", "testcontainers", "data-document", "postgresql", "eda", "fastapi", "granian", "hypercorn", "kafka", "rabbitmq", "redis", "cache", "client", "observability", "scheduling", "pii", "security", "cli", "shell", "web-fast", "web-fastapi", "full"] [package.metadata.requires-dev] dev = [ @@ -2323,6 +2341,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2773,6 +2810,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + [[package]] name = "thinc" version = "8.3.13"