Skip to content

Commit

Permalink
Merge branch 'main' into image_build
Browse files Browse the repository at this point in the history
  • Loading branch information
Tranquility2 authored May 11, 2024
2 parents 9206e8d + 690b9b4 commit 1f7f7f7
Show file tree
Hide file tree
Showing 13 changed files with 198 additions and 10 deletions.
5 changes: 4 additions & 1 deletion core/testcontainers/core/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,15 @@ def _create_connection_url(
def start(self) -> "DbContainer":
self._configure()
super().start()
self._transfer_seed()
self._connect()
return self

def _configure(self) -> None:
raise NotImplementedError


def _transfer_seed(self) -> None:
pass

class CustomContainer(DockerContainer):
"""
Expand Down
1 change: 1 addition & 0 deletions index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ testcontainers-python facilitates the use of Docker containers for functional an
modules/kafka/README
modules/keycloak/README
modules/localstack/README
modules/memcached/README
modules/minio/README
modules/mongodb/README
modules/mssql/README
Expand Down
30 changes: 26 additions & 4 deletions modules/keycloak/testcontainers/keycloak/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@


class KeycloakContainer(DockerContainer):
has_realm_imports = False

"""
Keycloak container.
Expand All @@ -43,22 +45,26 @@ def __init__(
username: Optional[str] = None,
password: Optional[str] = None,
port: int = 8080,
cmd: Optional[str] = _DEFAULT_DEV_COMMAND,
) -> None:
super().__init__(image=image)
self.username = username or os.environ.get("KEYCLOAK_ADMIN", "test")
self.password = password or os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "test")
self.port = port
self.with_exposed_ports(self.port)
self.cmd = cmd

def _configure(self) -> None:
self.with_env("KEYCLOAK_ADMIN", self.username)
self.with_env("KEYCLOAK_ADMIN_PASSWORD", self.password)
# Enable health checks
# see: https://www.keycloak.org/server/health#_relevant_options
self.with_env("KC_HEALTH_ENABLED", "true")
# Starting Keycloak in development mode
# Start Keycloak in development mode
# see: https://www.keycloak.org/server/configuration#_starting_keycloak_in_development_mode
self.with_command(_DEFAULT_DEV_COMMAND)
if self.has_realm_imports:
self.cmd += " --import-realm"
self.with_command(self.cmd)

def get_url(self) -> str:
host = self.get_container_host_ip()
Expand All @@ -67,10 +73,10 @@ def get_url(self) -> str:

@wait_container_is_ready(requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout)
def _readiness_probe(self) -> None:
# Keycloak provides an REST API endpoints for health checks: https://www.keycloak.org/server/health
# Keycloak provides REST API endpoints for health checks: https://www.keycloak.org/server/health
response = requests.get(f"{self.get_url()}/health/ready", timeout=1)
response.raise_for_status()
if self._command == _DEFAULT_DEV_COMMAND:
if _DEFAULT_DEV_COMMAND in self._command:
wait_for_logs(self, "Added user .* to realm .*")

def start(self) -> "KeycloakContainer":
Expand All @@ -79,6 +85,22 @@ def start(self) -> "KeycloakContainer":
self._readiness_probe()
return self

def with_realm_import_file(self, realm_import_file: str) -> "KeycloakContainer":
file = os.path.abspath(realm_import_file)
if not os.path.exists(file):
raise FileNotFoundError(f"Realm file {file} does not exist")
self.with_volume_mapping(file, "/opt/keycloak/data/import/realm.json")
self.has_realm_imports = True
return self

def with_realm_import_folder(self, realm_import_folder: str) -> "KeycloakContainer":
folder = os.path.abspath(realm_import_folder)
if not os.path.exists(folder):
raise FileNotFoundError(f"Realm folder {folder} does not exist")
self.with_volume_mapping(folder, "/opt/keycloak/data/import/")
self.has_realm_imports = True
return self

def get_client(self, **kwargs) -> KeycloakAdmin:
default_kwargs = {
"server_url": self.get_url(),
Expand Down
1 change: 1 addition & 0 deletions modules/memcached/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.. autoclass:: testcontainers.memcached.MemcachedContainer
17 changes: 17 additions & 0 deletions modules/memcached/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from setuptools import find_namespace_packages, setup

description = "Memcached component of testcontainers-python."

setup(
name="testcontainers-memcached",
version="0.0.1rc1",
packages=find_namespace_packages(),
description=description,
long_description=description,
long_description_content_type="text/x-rst",
url="/~https://github.com/testcontainers/testcontainers-python",
install_requires=[
"testcontainers-core",
],
python_requires=">=3.7",
)
59 changes: 59 additions & 0 deletions modules/memcached/testcontainers/memcached/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#
# 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.
import socket

from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_container_is_ready


class MemcachedNotReady(Exception):
pass


class MemcachedContainer(DockerContainer):
"""
Test container for Memcached. The example below spins up a Memcached server
Example:
.. doctest::
>>> from testcontainers.memcached import MemcachedContainer
>>> with MemcachedContainer() as memcached_container:
... host, port = memcached_container.get_host_and_port()
"""

def __init__(self, image="memcached:1", port_to_expose=11211, **kwargs):
super().__init__(image, **kwargs)
self.port_to_expose = port_to_expose
self.with_exposed_ports(port_to_expose)

@wait_container_is_ready(MemcachedNotReady)
def _connect(self):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
host = self.get_container_host_ip()
port = int(self.get_exposed_port(self.port_to_expose))
s.connect((host, port))
s.sendall(b"stats\n\r")
data = s.recv(1024)
if len(data) == 0:
raise MemcachedNotReady("Memcached not ready yet")

def start(self):
super().start()
self._connect()
return self

def get_host_and_port(self):
return self.get_container_host_ip(), int(self.get_exposed_port(self.port_to_expose))
28 changes: 28 additions & 0 deletions modules/memcached/tests/test_memcached.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import socket

from testcontainers.memcached import MemcachedContainer

import pytest


def test_memcached_host_and_exposed_port():
with MemcachedContainer("memcached:1.6-alpine") as memcached:
host, port = memcached.get_host_and_port()
assert host == "localhost"
assert port != 11211


@pytest.mark.parametrize("image", ["memcached:1.6-bookworm", "memcached:1.6-alpine"])
def test_memcached_can_connect_and_retrieve_data(image):
with MemcachedContainer(image) as memcached:
host, port = memcached.get_host_and_port()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))
s.sendall(b"stats\n\r")
data = s.recv(1024)
assert len(data) > 0, "We should have received some data from memcached"

pid_stat, uptime_stat, *_ = data.decode().split("\r\n")

assert pid_stat.startswith("STAT pid")
assert uptime_stat.startswith("STAT uptime")
32 changes: 32 additions & 0 deletions modules/mysql/testcontainers/mysql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
# License for the specific language governing permissions and limitations
# under the License.
import re
import tarfile
from io import BytesIO
from os import environ
from pathlib import Path
from typing import Optional

from testcontainers.core.generic import DbContainer
Expand Down Expand Up @@ -40,6 +43,22 @@ class MySqlContainer(DbContainer):
... with engine.begin() as connection:
... result = connection.execute(sqlalchemy.text("select version()"))
... version, = result.fetchone()
The optional :code:`seed` parameter enables arbitrary SQL files to be loaded.
This is perfect for schema and sample data. This works by mounting the seed to
`/docker-entrypoint-initdb./d`, which containerized MySQL are set up to load
automatically.
.. doctest::
>>> import sqlalchemy
>>> from testcontainers.mysql import MySqlContainer
>>> with MySqlContainer(seed="../../tests/seeds/") as mysql:
... engine = sqlalchemy.create_engine(mysql.get_connection_url())
... with engine.begin() as connection:
... query = "select * from stuff" # Can now rely on schema/data
... result = connection.execute(sqlalchemy.text(query))
... first_stuff, = result.fetchone()
"""

def __init__(
Expand All @@ -50,6 +69,7 @@ def __init__(
password: Optional[str] = None,
dbname: Optional[str] = None,
port: int = 3306,
seed: Optional[str] = None,
**kwargs,
) -> None:
raise_for_deprecated_parameter(kwargs, "MYSQL_USER", "username")
Expand All @@ -67,6 +87,7 @@ def __init__(

if self.username == "root":
self.root_password = self.password
self.seed = seed

def _configure(self) -> None:
self.with_env("MYSQL_ROOT_PASSWORD", self.root_password)
Expand All @@ -86,3 +107,14 @@ 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
)

def _transfer_seed(self) -> None:
if self.seed is None:
return
src_path = Path(self.seed)
dest_path = "/docker-entrypoint-initdb.d/"
with BytesIO() as archive, tarfile.TarFile(fileobj=archive, mode="w") as tar:
for filename in src_path.iterdir():
tar.add(filename.absolute(), arcname=filename.relative_to(src_path))
archive.seek(0)
self.get_wrapped_container().put_archive(dest_path, archive)
6 changes: 6 additions & 0 deletions modules/mysql/tests/seeds/01-schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Sample SQL schema, no data
CREATE TABLE `stuff` (
`id` mediumint NOT NULL AUTO_INCREMENT,
`name` VARCHAR(63) NOT NULL,
PRIMARY KEY (`id`)
);
3 changes: 3 additions & 0 deletions modules/mysql/tests/seeds/02-seeds.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Sample data, to be loaded after the schema
INSERT INTO stuff (name)
VALUES ("foo"), ("bar"), ("qux"), ("frob");
13 changes: 13 additions & 0 deletions modules/mysql/tests/test_mysql.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from pathlib import Path
import re
from unittest import mock

Expand Down Expand Up @@ -29,6 +30,18 @@ def test_docker_run_legacy_mysql():
assert row[0].startswith("5.7.44")


@pytest.mark.skipif(is_arm(), reason="mysql container not available for ARM")
def test_docker_run_mysql_8_seed():
# Avoid pytest CWD path issues
SEEDS_PATH = (Path(__file__).parent / "seeds").absolute()
config = MySqlContainer("mysql:8", seed=SEEDS_PATH)
with config as mysql:
engine = sqlalchemy.create_engine(mysql.get_connection_url())
with engine.begin() as connection:
result = connection.execute(sqlalchemy.text("select * from stuff"))
assert len(list(result)) == 4, "Should have gotten all the stuff"


@pytest.mark.parametrize("version", ["11.3.2", "10.11.7"])
def test_docker_run_mariadb(version: str):
with MySqlContainer(f"mariadb:{version}") as mariadb:
Expand Down
11 changes: 6 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ packages = [
{ include = "testcontainers", from = "modules/kafka" },
{ include = "testcontainers", from = "modules/keycloak" },
{ include = "testcontainers", from = "modules/localstack" },
{ include = "testcontainers", from = "modules/memcached" },
{ include = "testcontainers", from = "modules/minio" },
{ include = "testcontainers", from = "modules/mongodb" },
{ include = "testcontainers", from = "modules/mssql" },
Expand Down Expand Up @@ -111,6 +112,7 @@ k3s = ["kubernetes", "pyyaml"]
kafka = []
keycloak = ["python-keycloak"]
localstack = ["boto3"]
memcached = []
minio = ["minio"]
mongodb = ["pymongo"]
mssql = ["sqlalchemy", "pymssql"]
Expand Down

0 comments on commit 1f7f7f7

Please sign in to comment.