Skip to content

Commit 2781911

Browse files
committed
Merge branch 'main' into image_build
2 parents 9206e8d + 690b9b4 commit 2781911

File tree

13 files changed

+198
-9
lines changed

13 files changed

+198
-9
lines changed

core/testcontainers/core/generic.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,16 @@ def _create_connection_url(
7171
def start(self) -> "DbContainer":
7272
self._configure()
7373
super().start()
74+
self._transfer_seed()
7475
self._connect()
7576
return self
7677

7778
def _configure(self) -> None:
7879
raise NotImplementedError
7980

81+
def _transfer_seed(self) -> None:
82+
pass
83+
8084

8185
class CustomContainer(DockerContainer):
8286
"""

index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ testcontainers-python facilitates the use of Docker containers for functional an
2727
modules/kafka/README
2828
modules/keycloak/README
2929
modules/localstack/README
30+
modules/memcached/README
3031
modules/minio/README
3132
modules/mongodb/README
3233
modules/mssql/README

modules/keycloak/testcontainers/keycloak/__init__.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424

2525
class KeycloakContainer(DockerContainer):
26+
has_realm_imports = False
27+
2628
"""
2729
Keycloak container.
2830
@@ -43,22 +45,26 @@ def __init__(
4345
username: Optional[str] = None,
4446
password: Optional[str] = None,
4547
port: int = 8080,
48+
cmd: Optional[str] = _DEFAULT_DEV_COMMAND,
4649
) -> None:
4750
super().__init__(image=image)
4851
self.username = username or os.environ.get("KEYCLOAK_ADMIN", "test")
4952
self.password = password or os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "test")
5053
self.port = port
5154
self.with_exposed_ports(self.port)
55+
self.cmd = cmd
5256

5357
def _configure(self) -> None:
5458
self.with_env("KEYCLOAK_ADMIN", self.username)
5559
self.with_env("KEYCLOAK_ADMIN_PASSWORD", self.password)
5660
# Enable health checks
5761
# see: https://www.keycloak.org/server/health#_relevant_options
5862
self.with_env("KC_HEALTH_ENABLED", "true")
59-
# Starting Keycloak in development mode
63+
# Start Keycloak in development mode
6064
# see: https://www.keycloak.org/server/configuration#_starting_keycloak_in_development_mode
61-
self.with_command(_DEFAULT_DEV_COMMAND)
65+
if self.has_realm_imports:
66+
self.cmd += " --import-realm"
67+
self.with_command(self.cmd)
6268

6369
def get_url(self) -> str:
6470
host = self.get_container_host_ip()
@@ -67,10 +73,10 @@ def get_url(self) -> str:
6773

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

7682
def start(self) -> "KeycloakContainer":
@@ -79,6 +85,22 @@ def start(self) -> "KeycloakContainer":
7985
self._readiness_probe()
8086
return self
8187

88+
def with_realm_import_file(self, realm_import_file: str) -> "KeycloakContainer":
89+
file = os.path.abspath(realm_import_file)
90+
if not os.path.exists(file):
91+
raise FileNotFoundError(f"Realm file {file} does not exist")
92+
self.with_volume_mapping(file, "/opt/keycloak/data/import/realm.json")
93+
self.has_realm_imports = True
94+
return self
95+
96+
def with_realm_import_folder(self, realm_import_folder: str) -> "KeycloakContainer":
97+
folder = os.path.abspath(realm_import_folder)
98+
if not os.path.exists(folder):
99+
raise FileNotFoundError(f"Realm folder {folder} does not exist")
100+
self.with_volume_mapping(folder, "/opt/keycloak/data/import/")
101+
self.has_realm_imports = True
102+
return self
103+
82104
def get_client(self, **kwargs) -> KeycloakAdmin:
83105
default_kwargs = {
84106
"server_url": self.get_url(),

modules/memcached/README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.. autoclass:: testcontainers.memcached.MemcachedContainer

modules/memcached/setup.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from setuptools import find_namespace_packages, setup
2+
3+
description = "Memcached component of testcontainers-python."
4+
5+
setup(
6+
name="testcontainers-memcached",
7+
version="0.0.1rc1",
8+
packages=find_namespace_packages(),
9+
description=description,
10+
long_description=description,
11+
long_description_content_type="text/x-rst",
12+
url="https://github.com/testcontainers/testcontainers-python",
13+
install_requires=[
14+
"testcontainers-core",
15+
],
16+
python_requires=">=3.7",
17+
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
import socket
14+
15+
from testcontainers.core.container import DockerContainer
16+
from testcontainers.core.waiting_utils import wait_container_is_ready
17+
18+
19+
class MemcachedNotReady(Exception):
20+
pass
21+
22+
23+
class MemcachedContainer(DockerContainer):
24+
"""
25+
Test container for Memcached. The example below spins up a Memcached server
26+
27+
Example:
28+
29+
.. doctest::
30+
31+
>>> from testcontainers.memcached import MemcachedContainer
32+
33+
>>> with MemcachedContainer() as memcached_container:
34+
... host, port = memcached_container.get_host_and_port()
35+
"""
36+
37+
def __init__(self, image="memcached:1", port_to_expose=11211, **kwargs):
38+
super().__init__(image, **kwargs)
39+
self.port_to_expose = port_to_expose
40+
self.with_exposed_ports(port_to_expose)
41+
42+
@wait_container_is_ready(MemcachedNotReady)
43+
def _connect(self):
44+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
45+
host = self.get_container_host_ip()
46+
port = int(self.get_exposed_port(self.port_to_expose))
47+
s.connect((host, port))
48+
s.sendall(b"stats\n\r")
49+
data = s.recv(1024)
50+
if len(data) == 0:
51+
raise MemcachedNotReady("Memcached not ready yet")
52+
53+
def start(self):
54+
super().start()
55+
self._connect()
56+
return self
57+
58+
def get_host_and_port(self):
59+
return self.get_container_host_ip(), int(self.get_exposed_port(self.port_to_expose))
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import socket
2+
3+
from testcontainers.memcached import MemcachedContainer
4+
5+
import pytest
6+
7+
8+
def test_memcached_host_and_exposed_port():
9+
with MemcachedContainer("memcached:1.6-alpine") as memcached:
10+
host, port = memcached.get_host_and_port()
11+
assert host == "localhost"
12+
assert port != 11211
13+
14+
15+
@pytest.mark.parametrize("image", ["memcached:1.6-bookworm", "memcached:1.6-alpine"])
16+
def test_memcached_can_connect_and_retrieve_data(image):
17+
with MemcachedContainer(image) as memcached:
18+
host, port = memcached.get_host_and_port()
19+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
20+
s.connect((host, port))
21+
s.sendall(b"stats\n\r")
22+
data = s.recv(1024)
23+
assert len(data) > 0, "We should have received some data from memcached"
24+
25+
pid_stat, uptime_stat, *_ = data.decode().split("\r\n")
26+
27+
assert pid_stat.startswith("STAT pid")
28+
assert uptime_stat.startswith("STAT uptime")

modules/mysql/testcontainers/mysql/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
# License for the specific language governing permissions and limitations
1212
# under the License.
1313
import re
14+
import tarfile
15+
from io import BytesIO
1416
from os import environ
17+
from pathlib import Path
1518
from typing import Optional
1619

1720
from testcontainers.core.generic import DbContainer
@@ -40,6 +43,22 @@ class MySqlContainer(DbContainer):
4043
... with engine.begin() as connection:
4144
... result = connection.execute(sqlalchemy.text("select version()"))
4245
... version, = result.fetchone()
46+
47+
The optional :code:`seed` parameter enables arbitrary SQL files to be loaded.
48+
This is perfect for schema and sample data. This works by mounting the seed to
49+
`/docker-entrypoint-initdb./d`, which containerized MySQL are set up to load
50+
automatically.
51+
52+
.. doctest::
53+
>>> import sqlalchemy
54+
>>> from testcontainers.mysql import MySqlContainer
55+
>>> with MySqlContainer(seed="../../tests/seeds/") as mysql:
56+
... engine = sqlalchemy.create_engine(mysql.get_connection_url())
57+
... with engine.begin() as connection:
58+
... query = "select * from stuff" # Can now rely on schema/data
59+
... result = connection.execute(sqlalchemy.text(query))
60+
... first_stuff, = result.fetchone()
61+
4362
"""
4463

4564
def __init__(
@@ -50,6 +69,7 @@ def __init__(
5069
password: Optional[str] = None,
5170
dbname: Optional[str] = None,
5271
port: int = 3306,
72+
seed: Optional[str] = None,
5373
**kwargs,
5474
) -> None:
5575
raise_for_deprecated_parameter(kwargs, "MYSQL_USER", "username")
@@ -67,6 +87,7 @@ def __init__(
6787

6888
if self.username == "root":
6989
self.root_password = self.password
90+
self.seed = seed
7091

7192
def _configure(self) -> None:
7293
self.with_env("MYSQL_ROOT_PASSWORD", self.root_password)
@@ -86,3 +107,14 @@ def get_connection_url(self) -> str:
86107
return super()._create_connection_url(
87108
dialect="mysql+pymysql", username=self.username, password=self.password, dbname=self.dbname, port=self.port
88109
)
110+
111+
def _transfer_seed(self) -> None:
112+
if self.seed is None:
113+
return
114+
src_path = Path(self.seed)
115+
dest_path = "/docker-entrypoint-initdb.d/"
116+
with BytesIO() as archive, tarfile.TarFile(fileobj=archive, mode="w") as tar:
117+
for filename in src_path.iterdir():
118+
tar.add(filename.absolute(), arcname=filename.relative_to(src_path))
119+
archive.seek(0)
120+
self.get_wrapped_container().put_archive(dest_path, archive)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Sample SQL schema, no data
2+
CREATE TABLE `stuff` (
3+
`id` mediumint NOT NULL AUTO_INCREMENT,
4+
`name` VARCHAR(63) NOT NULL,
5+
PRIMARY KEY (`id`)
6+
);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Sample data, to be loaded after the schema
2+
INSERT INTO stuff (name)
3+
VALUES ("foo"), ("bar"), ("qux"), ("frob");

0 commit comments

Comments
 (0)