From 34edb4a822dee52621b12d5c50564ab525fed523 Mon Sep 17 00:00:00 2001 From: oysand Date: Fri, 21 Aug 2026 14:46:30 +0200 Subject: [PATCH 1/2] Use non-deprecated testcontainers postgres module testcontainers.postgres is a re-export shim that emits a DeprecationWarning on import. The implementation now lives in testcontainers.community.postgres, which exposes an identical PostgresContainer. --- robotics_integration_tests/custom_containers/postgres.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robotics_integration_tests/custom_containers/postgres.py b/robotics_integration_tests/custom_containers/postgres.py index 81b26e1..0a57564 100644 --- a/robotics_integration_tests/custom_containers/postgres.py +++ b/robotics_integration_tests/custom_containers/postgres.py @@ -1,5 +1,5 @@ from docker.models.networks import Network -from testcontainers.postgres import PostgresContainer +from testcontainers.community.postgres import PostgresContainer from robotics_integration_tests.settings.settings import settings From 6947ff875212acf19ed95e9c609f07ebae4f9fc5 Mon Sep 17 00:00:00 2001 From: oysand Date: Fri, 21 Aug 2026 14:47:25 +0200 Subject: [PATCH 2/2] Fix container log streaming lifecycle The log streaming thread was started in __init__, before the container existed. Its first statement called get_wrapped_container(), which raises ContainerStartException while _container is None rather than returning None as an earlier testcontainers API did. The thread therefore died immediately every time, so container logs were never streamed and each container produced a PytestUnhandledThreadExceptionWarning. Start the thread from start() instead, once the container is guaranteed to exist, which also removes the need to poll for it. Mark it as a daemon, stop and join it in stop(), and tolerate the stream failing when the container is removed during teardown. --- .../stream_logging_docker_container.py | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/robotics_integration_tests/custom_containers/stream_logging_docker_container.py b/robotics_integration_tests/custom_containers/stream_logging_docker_container.py index a21845b..78412b5 100644 --- a/robotics_integration_tests/custom_containers/stream_logging_docker_container.py +++ b/robotics_integration_tests/custom_containers/stream_logging_docker_container.py @@ -1,11 +1,13 @@ -import time -from threading import Thread -from typing import Optional, Any +from threading import Event, Thread +from typing import Optional, Any, Self from loguru import logger from testcontainers.core.container import DockerContainer from testcontainers.core.waiting_utils import WaitStrategy +# How long to wait for the log streaming thread to notice the container is gone. +LOGGING_THREAD_JOIN_TIMEOUT_SECONDS: float = 5.0 + class StreamLoggingDockerContainer(DockerContainer): def __init__( @@ -22,11 +24,38 @@ def __init__( **kwargs, ) - self.logging_thread: Thread = Thread(target=self._stream_logs) + self.logging_thread: Optional[Thread] = None + self._stop_logging: Event = Event() + + def start(self) -> Self: + # The thread must only be started once the container exists, since + # get_wrapped_container() raises ContainerStartException until then. + super().start() + + self._stop_logging.clear() + self.logging_thread = Thread(target=self._stream_logs, daemon=True) self.logging_thread.start() + return self + + def stop(self, force: bool = True, delete_volume: bool = True) -> None: + self._stop_logging.set() + + # Removing the container terminates the blocking log stream. + super().stop(force=force, delete_volume=delete_volume) + + if self.logging_thread is not None: + self.logging_thread.join(timeout=LOGGING_THREAD_JOIN_TIMEOUT_SECONDS) + self.logging_thread = None + def _stream_logs(self) -> None: - while not self.get_wrapped_container(): - time.sleep(0.1) - for line in self.get_wrapped_container().logs(stream=True, follow=True): - logger.info(f"{self._name}: {line.decode().rstrip()}") + try: + for line in self.get_wrapped_container().logs(stream=True, follow=True): + if self._stop_logging.is_set(): + return + logger.info(f"{self._name}: {line.decode().rstrip()}") + except Exception as exception: + # The stream is expected to fail once the container is removed + # during teardown. Anything else is not worth failing a test over. + if not self._stop_logging.is_set(): + logger.debug(f"{self._name}: log streaming stopped: {exception}")