Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ dev = [
"hypothesis",
"deepdiff",
"pytest-xdist",
"testcontainers",
"pymysql",
"cryptography",

]
docs = [
"mkdocs-material",
Expand Down
86 changes: 72 additions & 14 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,79 @@
import contextlib
import json
from collections.abc import Iterator
import os
import re
from collections.abc import Generator, Iterator
from pathlib import Path
from typing import Any, NamedTuple

import _pytest.mark
import httpx
import pytest
from _pytest.config import Config
import sqlalchemy
from _pytest.nodes import Item
from dotenv import load_dotenv
from fastapi.testclient import TestClient
from sqlalchemy import Connection, Engine, text
from testcontainers.mysql import LogMessageWaitStrategy, MySqlContainer

from database.setup import expdb_database, user_database
from main import create_api
from routers.dependencies import expdb_connection, userdb_connection

load_dotenv()

PHP_API_URL = "http://openml-php-rest-api:80/api/v1/json"


@pytest.fixture(scope="session", autouse=True)
def override_testcontainers_connect() -> None:
"""
Override MySqlContainer._connect once per test session.
Applied automatically everywhere.
"""

def _connect(self: MySqlContainer) -> None:
wait_strategy = LogMessageWaitStrategy(
re.compile(
r".*: ready for connections",
flags=re.DOTALL | re.MULTILINE,
)
)
wait_strategy.wait_until_ready(self)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🌐 Web query:

testcontainers-python MySqlContainer wait strategy customize public API

💡 Result:

In testcontainers-python, MySqlContainer doesn’t expose a Java-style “wait strategy” hook on the class itself; the public API for customizing readiness is via the waiting utilities (log-based waiting) and/or by subclassing and overriding the connection check.

1) Log-based waiting (public API)

Use testcontainers.core.waiting_utils.wait_for_logs(container, predicate, timeout=None, interval=1) after the container is started (or inside a custom container’s startup flow). The predicate can be a regex string or a callable. The function supports timeout and interval. [1]

from testcontainers.mysql import MySqlContainer
from testcontainers.core.waiting_utils import wait_for_logs

with MySqlContainer("mysql:8.0") as mysql:
    wait_for_logs(mysql, r"ready for connections", timeout=60, interval=1)

MySqlContainer itself is the standard MySQL wrapper you’re already using. [2]

2) Connection-based waiting via @wait_container_is_ready (public API)

If you want “wait until I can connect” semantics (common for DBs), subclass MySqlContainer and decorate your _connect() with wait_container_is_ready(...) so failures are retried until success/timeout. [3]

import mysql.connector
from testcontainers.mysql import MySqlContainer
from testcontainers.core.waiting_utils import wait_container_is_ready

class MyReadyMySql(MySqlContainer):
    `@wait_container_is_ready`(mysql.connector.Error)
    def _connect(self):
        conn = mysql.connector.connect(
            host=self.get_container_host_ip(),
            port=int(self.get_exposed_port(3306)),
            user=self.username,
            password=self.password,
            database=self.dbname,
        )
        conn.close()

with MyReadyMySql("mysql:8.0") as mysql:
    ...

3) Tuning retry/timeout behavior

Retry limits/sleep/timeout for readiness waiting are controlled via testcontainers_config (global configuration). [3]

from testcontainers.core.config import testcontainers_config
testcontainers_config.timeout = 180
testcontainers_config.max_tries = 180
testcontainers_config.sleep_time = 1

Sources: [1] [2] [3]


Use wait_for_logs() or subclass with @wait_container_is_ready() instead of monkey-patching _connect.

Line 36 overrides a private method, which breaks on testcontainers-python upgrades if internals change. testcontainers-python provides public APIs:

  • wait_for_logs(): Call after container startup with a regex predicate for log-based readiness
  • Subclass + @wait_container_is_ready() decorator: Override _connect in a subclass with the decorator to handle retry logic safely

Example using wait_for_logs:

from testcontainers.mysql import MySqlContainer
from testcontainers.core.waiting_utils import wait_for_logs

with MySqlContainer("mysql:8.0") as mysql:
    wait_for_logs(mysql, r"ready for connections", timeout=60, interval=1)
🤖 Prompt for AI Agents
In `@tests/conftest.py` around lines 29 - 44, The fixture
override_testcontainers_connect is monkey-patching the private
MySqlContainer._connect; remove that override and instead use the public waiting
utilities—either call wait_for_logs(container, r"ready for connections",
timeout=60, interval=1) after container startup (use the
testcontainers.core.waiting_utils.wait_for_logs function) or create a subclass
(e.g., MySqlContainerWithWait) and implement _connect decorated with
`@wait_container_is_ready` to handle retry logic; in short: stop overriding
_connect directly in override_testcontainers_connect and switch to wait_for_logs
or a decorated subclass of MySqlContainer for log-based readiness.

MySqlContainer._connect = _connect # noqa: SLF001
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


@pytest.fixture(scope="session")
def mysql_container() -> MySqlContainer:
container = MySqlContainer(
os.environ.get(
"OPENML_DATABASES_OPENML_URL",
"openml/test-database:v0.1.20260204",
),
username=os.environ.get("OPENML_DATABASES_OPENML_USERNAME", ""),
password=os.environ.get("OPENML_DATABASES_OPENML_PASSWORD", ""),
dbname="openml_expdb",
)

container.start()
try:
yield container
finally:
container.stop()


@pytest.fixture
def expdb_test(mysql_container: MySqlContainer) -> Connection:
url = mysql_container.get_connection_url().replace("mysql://", "mysql+pymysql://")
engine = sqlalchemy.create_engine(url)

with engine.begin() as connection: # This starts a transaction
try:
yield connection
finally:
connection.rollback() # Rollback ALL test changes


@contextlib.contextmanager
def automatic_rollback(engine: Engine) -> Iterator[Connection]:
with engine.connect() as connection:
Expand All @@ -29,14 +84,14 @@ def automatic_rollback(engine: Engine) -> Iterator[Connection]:


@pytest.fixture
def expdb_test() -> Connection:
with automatic_rollback(expdb_database()) as connection:
yield connection


@pytest.fixture
def user_test() -> Connection:
with automatic_rollback(user_database()) as connection:
def user_test(mysql_container: MySqlContainer) -> Connection:
"""Get a connection to the user database using the testcontainer."""
url = mysql_container.get_connection_url()
url = url.replace("mysql://", "mysql+pymysql://")
url = url.replace("openml_expdb", "openml")

engine = sqlalchemy.create_engine(url)
with automatic_rollback(engine) as connection:
yield connection


Expand All @@ -47,12 +102,15 @@ def php_api() -> httpx.Client:


@pytest.fixture
def py_api(expdb_test: Connection, user_test: Connection) -> TestClient:
def py_api(expdb_test: Connection, user_test: Connection) -> Generator[TestClient, None, None]:
app = create_api()
# We use the lambda definitions because fixtures may not be called directly.
app.dependency_overrides[expdb_connection] = lambda: expdb_test
app.dependency_overrides[userdb_connection] = lambda: user_test
return TestClient(app)

client = TestClient(app)
yield client
client.close()


@pytest.fixture
Expand Down Expand Up @@ -109,7 +167,7 @@ def persisted_flow(flow: Flow, expdb_test: Connection) -> Iterator[Flow]:
expdb_test.commit()


def pytest_collection_modifyitems(config: Config, items: list[Item]) -> None: # noqa: ARG001
def pytest_collection_modifyitems(items: list[Item]) -> None:
for test_item in items:
for fixture in test_item.fixturenames: # type: ignore[attr-defined]
test_item.own_markers.append(_pytest.mark.Mark(fixture, (), {}))
Loading
Loading