diff --git a/Makefile b/Makefile index 19cd33a1..680b5d03 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,10 @@ build: ## Build the python package tests: ${TESTS} ## Run tests for each package ${TESTS}: %/tests: - uv run coverage run --parallel -m pytest -v $*/tests + uv run coverage run --parallel -m pytest -v $*/tests + +quick-core-tests: ## Run core tests excluding long_running + uv run coverage run --parallel -m pytest -v -m "not long_running" core/tests coverage: ## Target to combine and report coverage. uv run coverage combine @@ -61,7 +64,7 @@ clean-all: clean ## Remove all generated files and reset the local virtual envir rm -rf .venv # Targets that do not generate file-level artifacts. -.PHONY: clean docs doctests image tests ${TESTS} +.PHONY: clean docs doctests image tests quick-core-tests ${TESTS} # Implements this pattern for autodocumenting Makefiles: diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index cca5d65a..c9cd8c21 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -88,6 +88,8 @@ def read_tc_properties() -> dict[str, str]: @dataclass class TestcontainersConfiguration: + __test__ = False + def _render_bool(self, env_name: str, prop_name: str) -> bool: env_val = environ.get(env_name, None) if env_val is not None: diff --git a/core/tests/test_compose.py b/core/tests/test_compose.py index 755b8b17..ee39ec0c 100644 --- a/core/tests/test_compose.py +++ b/core/tests/test_compose.py @@ -1,3 +1,4 @@ +import logging import subprocess from pathlib import Path from re import split @@ -150,7 +151,7 @@ def test_compose_logs(): assert not line or container.Service in next(iter(line.split("|"))) -def test_compose_volumes(): +def test_compose_volumes(caplog): _file_in_volume = "/var/lib/example/data/hello" volumes = DockerCompose(context=FIXTURES / "basic_volume", keep_volumes=True) with volumes: @@ -167,8 +168,11 @@ def test_compose_volumes(): assert "hello" in stdout # third time we expect the file to be missing - with volumes, pytest.raises(subprocess.CalledProcessError): - volumes.exec_in_container(["cat", _file_in_volume], "alpine") + with caplog.at_level( + logging.CRITICAL, logger="testcontainers.compose.compose" + ): # suppress expected error logs about missing volume + with volumes, pytest.raises(subprocess.CalledProcessError): + volumes.exec_in_container(["cat", _file_in_volume], "alpine") # noinspection HttpUrlsUsage diff --git a/core/tests/test_docker_in_docker.py b/core/tests/test_docker_in_docker.py index 43ec020c..ada83c5f 100644 --- a/core/tests/test_docker_in_docker.py +++ b/core/tests/test_docker_in_docker.py @@ -179,6 +179,7 @@ def test_find_host_network_in_dood() -> None: assert DockerClient().find_host_network() == os.environ[EXPECTED_NETWORK_VAR] +@pytest.mark.long_running @pytest.mark.skipif( is_mac(), reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS", @@ -219,6 +220,7 @@ def test_dood(python_testcontainer_image: str) -> None: assert status["StatusCode"] == 0 +@pytest.mark.long_running @pytest.mark.skipif( is_mac(), reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS", diff --git a/core/tests/test_ryuk.py b/core/tests/test_ryuk.py index 0321f1a9..ed3bdd7c 100644 --- a/core/tests/test_ryuk.py +++ b/core/tests/test_ryuk.py @@ -1,4 +1,4 @@ -from time import sleep +from time import sleep, perf_counter import pytest from pytest import MonkeyPatch @@ -12,6 +12,27 @@ from testcontainers.core.waiting_utils import wait_for_logs +def _wait_for_container_removed(client: DockerClient, container_id: str, timeout: float = 30) -> None: + """Poll until a container is fully removed (raises NotFound).""" + start = perf_counter() + while perf_counter() - start < timeout: + try: + client.containers.get(container_id) + except NotFound: + return + sleep(0.5) + + try: + c = client.containers.get(container_id) + name = c.name + status = c.status + started_at = c.attrs.get("State", {}).get("StartedAt", "unknown") + detail = f"name={name}, status={status}, started_at={started_at}" + except NotFound: + detail = "container disappeared just after timeout" + raise TimeoutError(f"Container {container_id} was not removed within {timeout}s ({detail})") + + @pytest.mark.skipif( is_mac(), reason="Ryuk container reaping is unreliable on Docker Desktop for macOS due to VM-based container lifecycle handling", @@ -39,8 +60,11 @@ def test_wait_for_reaper(monkeypatch: MonkeyPatch): assert rs rs.close() - sleep(0.6) # Sleep until Ryuk reaps all dangling containers. 0.5 extra seconds for good measure. + # Ryuk will reap containers then auto-remove itself. + # Wait for the reaper container to disappear and once it's gone, all labeled containers are guaranteed reaped. + _wait_for_container_removed(docker_client, reaper_id) + # Verify both containers were reaped with pytest.raises(NotFound): docker_client.containers.get(container_id) with pytest.raises(NotFound): diff --git a/core/tests/test_wait_strategies.py b/core/tests/test_wait_strategies.py index da62f1fb..20f0e2c2 100644 --- a/core/tests/test_wait_strategies.py +++ b/core/tests/test_wait_strategies.py @@ -1,3 +1,4 @@ +import logging import re import time from datetime import timedelta @@ -528,7 +529,7 @@ def test_file_exists_wait_strategy_initialization(self, file_path): @patch("pathlib.Path.is_file") @patch("time.time") @patch("time.sleep") - def test_wait_until_ready(self, mock_sleep, mock_time, mock_is_file, file_exists, expected_behavior): + def test_wait_until_ready(self, mock_sleep, mock_time, mock_is_file, file_exists, expected_behavior, caplog): strategy = FileExistsWaitStrategy("/tmp/test.txt").with_startup_timeout(1) mock_container = Mock() @@ -547,7 +548,8 @@ def test_wait_until_ready(self, mock_sleep, mock_time, mock_is_file, file_exists mock_is_file.assert_called() else: with pytest.raises(TimeoutError, match="File.*did not exist within.*seconds"): - strategy.wait_until_ready(mock_container) + with caplog.at_level(logging.CRITICAL, logger="testcontainers.core.wait_strategies"): + strategy.wait_until_ready(mock_container) class TestCompositeWaitStrategy: @@ -615,7 +617,7 @@ def test_wait_until_ready_all_strategies_succeed(self): strategy2.wait_until_ready.assert_called_once_with(mock_container) strategy3.wait_until_ready.assert_called_once_with(mock_container) - def test_wait_until_ready_first_strategy_fails(self): + def test_wait_until_ready_first_strategy_fails(self, caplog): """Test that execution stops when first strategy fails.""" strategy1 = Mock() strategy2 = Mock() @@ -628,7 +630,8 @@ def test_wait_until_ready_first_strategy_fails(self): strategy1.wait_until_ready.side_effect = TimeoutError("First strategy failed") with pytest.raises(TimeoutError, match="First strategy failed"): - composite.wait_until_ready(mock_container) + with caplog.at_level(logging.CRITICAL, logger="testcontainers.core.wait_strategies"): + composite.wait_until_ready(mock_container) # Only first strategy should be called strategy1.wait_until_ready.assert_called_once_with(mock_container) diff --git a/pyproject.toml b/pyproject.toml index f983a2e3..d76d5852 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ rabbitmq = ["pika>=1"] redis = ["redis>=7"] registry = ["bcrypt>=5"] selenium = ["selenium>=4"] -scylla = ["cassandra-driver>=3"] +scylla = ["cassandra-driver>=3; python_version < '3.14'"] sftp = ["cryptography"] vault = [] weaviate = ["weaviate-client>=4"] @@ -120,7 +120,7 @@ test = [ "psycopg2-binary==2.9.11", "pg8000==1.31.5", "psycopg>=3", - "cassandra-driver>=3", + "cassandra-driver>=3; python_version < '3.14'", "kafka-python-ng>=2", "hvac>=2; python_version < '4.0'", "pymilvus>=2", @@ -277,6 +277,7 @@ log_cli = true log_cli_level = "INFO" markers = [ "inside_docker_check: mark test to be used to validate DinD/DooD is working as expected", + "long_running: mark test as very long running", ] filterwarnings = [ "ignore:The @wait_container_is_ready decorator is deprecated.*:DeprecationWarning", diff --git a/uv.lock b/uv.lock index 5b0f9e1c..76ad9504 100644 --- a/uv.lock +++ b/uv.lock @@ -483,7 +483,7 @@ name = "cassandra-driver" version = "3.29.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "geomet" }, + { name = "geomet", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/47/4e0fbdf02a7a418997f16f59feba26937d9973b979d3f23d79fbd8f6186f/cassandra_driver-3.29.3.tar.gz", hash = "sha256:ff6b82ee4533f6fd4474d833e693b44b984f58337173ee98ed76bce08721a636", size = 294612, upload-time = "2025-10-22T15:15:01.335Z" } wheels = [ @@ -1243,7 +1243,7 @@ name = "geomet" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "click", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/8c/dde022aa6747b114f6b14a7392871275dea8867e2bd26cddb80cc6d66620/geomet-1.1.0.tar.gz", hash = "sha256:51e92231a0ef6aaa63ac20c443377ba78a303fd2ecd179dc3567de79f3c11605", size = 28732, upload-time = "2023-11-14T15:43:36.764Z" } wheels = [ @@ -5002,7 +5002,7 @@ registry = [ { name = "bcrypt" }, ] scylla = [ - { name = "cassandra-driver" }, + { name = "cassandra-driver", marker = "python_full_version < '3.14'" }, ] selenium = [ { name = "selenium" }, @@ -5023,7 +5023,7 @@ weaviate = [ [package.dev-dependencies] dev = [ { name = "anyio" }, - { name = "cassandra-driver" }, + { name = "cassandra-driver", marker = "python_full_version < '3.14'" }, { name = "hvac", marker = "python_full_version < '4'" }, { name = "kafka-python-ng" }, { name = "mkdocs" }, @@ -5071,7 +5071,7 @@ lint = [ ] test = [ { name = "anyio" }, - { name = "cassandra-driver" }, + { name = "cassandra-driver", marker = "python_full_version < '3.14'" }, { name = "hvac", marker = "python_full_version < '4'" }, { name = "kafka-python-ng" }, { name = "paho-mqtt" }, @@ -5097,7 +5097,7 @@ requires-dist = [ { name = "bcrypt", marker = "extra == 'registry'", specifier = ">=5" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1" }, { name = "boto3", marker = "extra == 'localstack'", specifier = ">=1" }, - { name = "cassandra-driver", marker = "extra == 'scylla'", specifier = ">=3" }, + { name = "cassandra-driver", marker = "python_full_version < '3.14' and extra == 'scylla'", specifier = ">=3" }, { name = "chromadb-client", marker = "extra == 'chroma'", specifier = ">=1" }, { name = "clickhouse-driver", marker = "extra == 'clickhouse'" }, { name = "cryptography", marker = "extra == 'mailpit'" }, @@ -5147,7 +5147,7 @@ provides-extras = ["arangodb", "aws", "azurite", "cassandra", "clickhouse", "cos [package.metadata.requires-dev] dev = [ { name = "anyio", specifier = ">=4" }, - { name = "cassandra-driver", specifier = ">=3" }, + { name = "cassandra-driver", marker = "python_full_version < '3.14'", specifier = ">=3" }, { name = "hvac", marker = "python_full_version < '4'", specifier = ">=2" }, { name = "kafka-python-ng", specifier = ">=2" }, { name = "mkdocs", specifier = ">=1.5.3,<2.0.0" }, @@ -5193,7 +5193,7 @@ lint = [ ] test = [ { name = "anyio", specifier = ">=4" }, - { name = "cassandra-driver", specifier = ">=3" }, + { name = "cassandra-driver", marker = "python_full_version < '3.14'", specifier = ">=3" }, { name = "hvac", marker = "python_full_version < '4'", specifier = ">=2" }, { name = "kafka-python-ng", specifier = ">=2" }, { name = "paho-mqtt", specifier = ">=2" },