Skip to content

Commit

Permalink
fix(dependencies): remove usage of sqlalchemy in DB extras. Add def…
Browse files Browse the repository at this point in the history
…ault wait timeout for `wait_for_logs` (#525)

Removes usage of `sqlalchemy`, as part of the work described in
#526.

- Adds default timeout to the `wait_for_logs` waiting strategy, the same
timeout used by default in the `wait_container_is_ready` strategy.
- Changes wait strategy for `mysql` container to wait for logs
indicating that the DB engine is ready to accept connections (MySQL
performs a restart as part of its startup procedure, so the logs will
always appear twice.
- Add More tests for different `mysql` and `mariadb` versions to ensure
consistency in wait strategy.
- Remove x86 emulation for ARM devices for MariaDB, as it MariaDB images
support ARM architectures already.
- Change wait strategy for `oracle-free`, as the images produce a
consistent `DATABASE IS READY TO USE!` log message on startup.


Next steps will be to remove `sqlalchemy` as a bundled dependency
entirely, but I have not included it in this PR as I consider it a
bigger change than just changing wait strategies as an internal
implementation detail. I plan to do this as part of a bigger rework
where i remove the `DbContainer` class and standardize configuration
hooks and wait strategies across containers (not just DB containers, all
containers in need of a configuration and readiness step). See
#527 for
WIP.

---------

Co-authored-by: David Ankin <daveankin@gmail.com>
  • Loading branch information
santi and alexanderankin committed Apr 16, 2024
1 parent 11964de commit fefb9d0
Show file tree
Hide file tree
Showing 6 changed files with 41 additions and 19 deletions.
6 changes: 3 additions & 3 deletions core/testcontainers/core/waiting_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import re
import time
import traceback
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
from typing import TYPE_CHECKING, Any, Callable, Union

import wrapt

Expand Down Expand Up @@ -78,7 +78,7 @@ def wait_for(condition: Callable[..., bool]) -> bool:


def wait_for_logs(
container: "DockerContainer", predicate: Union[Callable, str], timeout: Optional[float] = None, interval: float = 1
container: "DockerContainer", predicate: Union[Callable, str], timeout: float = config.timeout, interval: float = 1
) -> float:
"""
Wait for the container to emit logs satisfying the predicate.
Expand All @@ -103,6 +103,6 @@ def wait_for_logs(
stderr = container.get_logs()[1].decode()
if predicate(stdout) or predicate(stderr):
return duration
if timeout and duration > timeout:
if duration > timeout:
raise TimeoutError(f"Container did not emit logs satisfying predicate in {timeout:.3f} " "seconds")
time.sleep(interval)
8 changes: 7 additions & 1 deletion modules/mssql/testcontainers/mssql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from testcontainers.core.generic import DbContainer
from testcontainers.core.utils import raise_for_deprecated_parameter
from testcontainers.core.waiting_utils import wait_container_is_ready


class SqlServerContainer(DbContainer):
Expand All @@ -16,7 +17,7 @@ class SqlServerContainer(DbContainer):
>>> import sqlalchemy
>>> from testcontainers.mssql import SqlServerContainer
>>> with SqlServerContainer() as mssql:
>>> with SqlServerContainer("mcr.microsoft.com/mssql/server:2022-CU12-ubuntu-22.04") as mssql:
... engine = sqlalchemy.create_engine(mssql.get_connection_url())
... with engine.begin() as connection:
... result = connection.execute(sqlalchemy.text("select @@VERSION"))
Expand Down Expand Up @@ -49,6 +50,11 @@ def _configure(self) -> None:
self.with_env("SQLSERVER_DBNAME", self.dbname)
self.with_env("ACCEPT_EULA", "Y")

@wait_container_is_ready(AssertionError)
def _connect(self) -> None:
status, _ = self.exec(f"/opt/mssql-tools/bin/sqlcmd -U {self.username} -P {self.password} -Q 'SELECT 1'")
assert status == 0, "Cannot run 'SELECT 1': container is not ready"

def get_connection_url(self) -> str:
return super()._create_connection_url(
dialect=self.dialect, username=self.username, password=self.password, dbname=self.dbname, port=self.port
Expand Down
14 changes: 9 additions & 5 deletions modules/mssql/tests/test_mssql.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import pytest
import sqlalchemy

from testcontainers.core.utils import is_arm
from testcontainers.mssql import SqlServerContainer


def test_docker_run_mssql():
image = "mcr.microsoft.com/azure-sql-edge"
dialect = "mssql+pymssql"
with SqlServerContainer(image, dialect=dialect) as mssql:
@pytest.mark.skipif(is_arm(), reason="mssql container not available for ARM")
@pytest.mark.parametrize("version", ["2022-CU12-ubuntu-22.04", "2019-CU25-ubuntu-20.04"])
def test_docker_run_mssql(version: str):
with SqlServerContainer(f"mcr.microsoft.com/mssql/server:{version}", password="1Secure*Password2") as mssql:
engine = sqlalchemy.create_engine(mssql.get_connection_url())
with engine.begin() as connection:
result = connection.execute(sqlalchemy.text("select @@servicename"))
for row in result:
assert row[0] == "MSSQLSERVER"

with SqlServerContainer(image, password="1Secure*Password2", dialect=dialect) as mssql:

def test_docker_run_azure_sql_edge():
with SqlServerContainer("mcr.microsoft.com/azure-sql-edge:1.0.7") as mssql:
engine = sqlalchemy.create_engine(mssql.get_connection_url())
with engine.begin() as connection:
result = connection.execute(sqlalchemy.text("select @@servicename"))
Expand Down
8 changes: 8 additions & 0 deletions modules/mysql/testcontainers/mysql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import re
from os import environ
from typing import Optional

from testcontainers.core.generic import DbContainer
from testcontainers.core.utils import raise_for_deprecated_parameter
from testcontainers.core.waiting_utils import wait_for_logs


class MySqlContainer(DbContainer):
Expand Down Expand Up @@ -74,6 +76,12 @@ def _configure(self) -> None:
self.with_env("MYSQL_USER", self.username)
self.with_env("MYSQL_PASSWORD", self.password)

def _connect(self) -> None:
wait_for_logs(
self,
re.compile(".*: ready for connections.*: ready for connections.*", flags=re.DOTALL | re.MULTILINE).search,
)

def get_connection_url(self) -> str:
return super()._create_connection_url(
dialect="mysql+pymysql", username=self.username, password=self.password, dbname=self.dbname, port=self.port
Expand Down
20 changes: 10 additions & 10 deletions modules/mysql/tests/test_mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,41 @@
from testcontainers.mysql import MySqlContainer


@pytest.mark.skipif(is_arm(), reason="mysql container not available for ARM")
def test_docker_run_mysql():
config = MySqlContainer("mysql:5.7.17")
config = MySqlContainer("mysql:8.3.0")
with config as mysql:
engine = sqlalchemy.create_engine(mysql.get_connection_url())
with engine.begin() as connection:
result = connection.execute(sqlalchemy.text("select version()"))
for row in result:
assert row[0].startswith("5.7.17")
assert row[0].startswith("8.3.0")


@pytest.mark.skipif(is_arm(), reason="mysql container not available for ARM")
def test_docker_run_mysql_8():
config = MySqlContainer("mysql:8")
def test_docker_run_legacy_mysql():
config = MySqlContainer("mysql:5.7.44")
with config as mysql:
engine = sqlalchemy.create_engine(mysql.get_connection_url())
with engine.begin() as connection:
result = connection.execute(sqlalchemy.text("select version()"))
for row in result:
assert row[0].startswith("8")
assert row[0].startswith("5.7.44")


def test_docker_run_mariadb():
with MySqlContainer("mariadb:10.6.5").maybe_emulate_amd64() as mariadb:
@pytest.mark.parametrize("version", ["11.3.2", "10.11.7"])
def test_docker_run_mariadb(version: str):
with MySqlContainer(f"mariadb:{version}") as mariadb:
engine = sqlalchemy.create_engine(mariadb.get_connection_url())
with engine.begin() as connection:
result = connection.execute(sqlalchemy.text("select version()"))
for row in result:
assert row[0].startswith("10.6.5")
assert row[0].startswith(version)


def test_docker_env_variables():
with (
mock.patch.dict("os.environ", MYSQL_USER="demo", MYSQL_DATABASE="custom_db"),
MySqlContainer("mariadb:10.6.5").with_bind_ports(3306, 32785).maybe_emulate_amd64() as container,
MySqlContainer("mariadb:10.6.5").with_bind_ports(3306, 32785) as container,
):
url = container.get_connection_url()
pattern = r"mysql\+pymysql:\/\/demo:test@[\w,.]+:(3306|32785)\/custom_db"
Expand Down
4 changes: 4 additions & 0 deletions modules/oracle-free/testcontainers/oracle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Optional

from testcontainers.core.generic import DbContainer
from testcontainers.core.waiting_utils import wait_for_logs


class OracleDbContainer(DbContainer):
Expand Down Expand Up @@ -57,6 +58,9 @@ def get_connection_url(self) -> str:
) + "/?service_name={}".format(self.dbname or "FREEPDB1")
# Default DB is "FREEPDB1"

def _connect(self) -> None:
wait_for_logs(self, "DATABASE IS READY TO USE!")

def _configure(self) -> None:
# if self.oracle_password is not None:
# self.with_env("ORACLE_PASSWORD", self.oracle_password)
Expand Down

0 comments on commit fefb9d0

Please sign in to comment.