Skip to content

feat: reusable containers #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a933873
feat: reusable containers
matthiasschaub Jul 2, 2024
f0e2bc7
docs: add documentation about reusable containers
matthiasschaub Jul 3, 2024
08e33ba
test: additional testcase for reusable containers
matthiasschaub Jul 3, 2024
d2a83bc
test: add newlines for better readability
matthiasschaub Jul 3, 2024
c781606
warn user if ryuk is disabled but with_reuse used
matthiasschaub Jul 3, 2024
dd429e7
docs: fix code highlighting
matthiasschaub Jul 3, 2024
e87e782
fix: use Union instead of | for type hint
matthiasschaub Jul 7, 2024
c656660
refactor: remove TODO comment
matthiasschaub Jul 8, 2024
efb1265
docs: update section on reusable containers
matthiasschaub Jul 8, 2024
d4445d6
feat(reuse): do not change contract of stop method
matthiasschaub Aug 2, 2024
1ea9ed1
feat(reuse): do not create Ryuk cleanup instance
matthiasschaub Aug 2, 2024
ea6fec7
refactor: move hash generation into if clause
matthiasschaub Aug 3, 2024
7c1e8e7
Merge remote-tracking branch 'origin/main' into reusable_containers
matthiasschaub Dec 30, 2024
78b137c
fix: milvus healthcheck: use correct requests errors (#759)
alexanderankin Jan 21, 2025
9317736
chore(main): release testcontainers 4.9.1 (#748)
github-actions[bot] Jan 21, 2025
3e783a8
fix(core): multiple container start invocations with custom labels (#…
neykov Feb 11, 2025
f0bb0f5
docs: Fixed typo in CONTRIBUTING.md (#767)
max-pfeiffer Feb 11, 2025
b1642e9
fix(keycloak): Fixed Keycloak testcontainer for latest version v26.1.…
max-pfeiffer Feb 11, 2025
2113561
Merge branch 'main' into reusable_containers
matthiasschaub Feb 12, 2025
2620d7f
fix: Change env var disabling OpenSearch security plugin (#773)
oelhammouchi Feb 22, 2025
7517297
fix(core): create_label test (#771)
neykov Feb 24, 2025
46913c1
fix(scylla): scylla get cluster method (#778)
alexanderankin Feb 26, 2025
a0785d7
chore(main): release testcontainers 4.9.2 (#770)
github-actions[bot] Mar 5, 2025
f979525
fix(security): Update track-modules job (#787)
Tranquility2 Mar 17, 2025
2f9139c
feat: Add SocatContainer (#795)
eddumelendez Apr 1, 2025
9497a45
fix(ollama): make device request a list (#799)
alexanderankin Apr 2, 2025
46feb1e
chore(main): release testcontainers 4.10.0 (#798)
github-actions[bot] Apr 2, 2025
6817582
fix(core): Determine docker socket for rootless docker (#779)
CarliJoy Apr 2, 2025
f7c29cb
fix(core): change with_command type to include list of strings (#789)
AmirHosein-Gharaati Apr 2, 2025
ab2a1ab
fix: use connection mode override function in config (#775)
alexanderankin Apr 2, 2025
cc02f94
fix(core): Add kwargs to image build (#708)
Tranquility2 Apr 2, 2025
0ae704a
fix(compose): use provided docker command instead of default (#785)
AmirHosein-Gharaati Apr 2, 2025
e9e40f9
chore: update poetry version for more reliable docs build (#803)
alexanderankin Apr 3, 2025
0615c29
Merge branch 'main' into reusable_containers
matthiasschaub Apr 16, 2025
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
2 changes: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "4.9.0"
".": "4.10.0"
}
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ You need to have the following tools available to you:
- Run `make install` to get `poetry` to install all dependencies and set up `pre-commit`
- **Recommended**: Run `make` or `make help` to see other commands available to you.
- After this, you should have a working virtual environment and proceed with writing code with your favourite IDE
- **TIP**: You can run `make core/tests` or `make module/<my-module>/tests` to run the tests specifically for that to speed up feedback cycles
- **TIP**: You can run `make core/tests` or `make modules/<my-module>/tests` to run the tests specifically for that to speed up feedback cycles
- You can also run `make lint` to run the `pre-commit` for the entire codebase.


Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/ci-community.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,20 @@ jobs:
- name: Checkout contents
uses: actions/checkout@v4
with:
fetch-depth: 0 # recommended by tj-actions/changed-files
fetch-depth: 0
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v42
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
with:
path: "./modules"
diff_relative: true
dir_names: true
dir_names_exclude_current_dir: true
json: true
base: ${{ github.ref }}
list-files: 'json'
filters: |
modules:
- 'modules/**'
- name: Compute modules from files
id: compute-changes
run: |
modules=$(echo "${{ steps.changed-files.outputs.all_changed_files }}" | jq '.[] | split("/") | first' | jq -s -c '. | unique')
modules=$(echo "${{ toJson(steps.changed-files.outputs.modules_files) }}" | jq '.[] | split("/") | nth(1)' | jq -s -c '. | unique')
echo "computed_modules=$modules"
echo "computed_modules=$modules" >> $GITHUB_OUTPUT
outputs:
Expand Down
2 changes: 1 addition & 1 deletion .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ build:
# https://github.com/readthedocs/readthedocs.org/issues/4912#issuecomment-1143587902s
jobs:
post_install:
- pip install poetry==1.7.1 # match version from poetry.lock
- pip install poetry==2.1.2 # match version from poetry.lock
- poetry config virtualenvs.create false
- poetry install --all-extras
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog

## [4.10.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.9.2...testcontainers-v4.10.0) (2025-04-02)


### Features

* Add SocatContainer ([#795](https://github.com/testcontainers/testcontainers-python/issues/795)) ([2f9139c](https://github.com/testcontainers/testcontainers-python/commit/2f9139ca3ea9fba36325373b63635a5f539a3003))


### Bug Fixes

* **ollama:** make device request a list ([#799](https://github.com/testcontainers/testcontainers-python/issues/799)) ([9497a45](https://github.com/testcontainers/testcontainers-python/commit/9497a45c39d13761aa3dd30dd5605676cbbe4b46))
* **security:** Update track-modules job ([#787](https://github.com/testcontainers/testcontainers-python/issues/787)) ([f979525](https://github.com/testcontainers/testcontainers-python/commit/f97952505eba089f9cbbc979f8091dafbf520669))

## [4.9.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.9.1...testcontainers-v4.9.2) (2025-02-26)


### Bug Fixes

* Change env var disabling OpenSearch security plugin ([#773](https://github.com/testcontainers/testcontainers-python/issues/773)) ([2620d7f](https://github.com/testcontainers/testcontainers-python/commit/2620d7fb1157caa18c3bef4bf2f9b3b79cd2f075))
* **core:** create_label test ([#771](https://github.com/testcontainers/testcontainers-python/issues/771)) ([7517297](https://github.com/testcontainers/testcontainers-python/commit/751729722a013b46f67c09b4318b1b3d92b98008))
* **core:** multiple container start invocations with custom labels ([#769](https://github.com/testcontainers/testcontainers-python/issues/769)) ([3e783a8](https://github.com/testcontainers/testcontainers-python/commit/3e783a80aa11b9c87201404a895d922624f0d451))
* **keycloak:** Fixed Keycloak testcontainer for latest version v26.1.0 ([#766](https://github.com/testcontainers/testcontainers-python/issues/766)) ([b1642e9](https://github.com/testcontainers/testcontainers-python/commit/b1642e98c4d349564c4365782d1b58c9810b719a))
* **scylla:** scylla get cluster method ([#778](https://github.com/testcontainers/testcontainers-python/issues/778)) ([46913c1](https://github.com/testcontainers/testcontainers-python/commit/46913c18a8b6f37bf8dc193828148926b6fc56a8))


### Documentation

* Fixed typo in CONTRIBUTING.md ([#767](https://github.com/testcontainers/testcontainers-python/issues/767)) ([f0bb0f5](https://github.com/testcontainers/testcontainers-python/commit/f0bb0f54bea83885698bd137e24c397498709362))

## [4.9.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.9.0...testcontainers-v4.9.1) (2025-01-21)


### Bug Fixes

* milvus healthcheck: use correct requests errors ([#759](https://github.com/testcontainers/testcontainers-python/issues/759)) ([78b137c](https://github.com/testcontainers/testcontainers-python/commit/78b137cfe53fc81eb8d5d858e98610fb6a8792ad))
* **mysql:** add dialect parameter instead of hardcoded mysql dialect ([#739](https://github.com/testcontainers/testcontainers-python/issues/739)) ([8d77bd3](https://github.com/testcontainers/testcontainers-python/commit/8d77bd3541e1c5e73c7ed5d5bd3c0d7bb617f5c0))
* **tests:** replace dind-test direct docker usage with sdk ([#750](https://github.com/testcontainers/testcontainers-python/issues/750)) ([ace2a7d](https://github.com/testcontainers/testcontainers-python/commit/ace2a7d143fb80576ddc0859a9106aa8652f2356))

## [4.9.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.8.2...testcontainers-v4.9.0) (2024-11-26)


Expand Down
4 changes: 3 additions & 1 deletion core/testcontainers/compose/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ class DockerCompose:
The list of services to use from this DockerCompose.
client_args:
arguments to pass to docker.from_env()
docker_command_path:
The docker compose command to run.

Example:

Expand Down Expand Up @@ -195,7 +197,7 @@ def docker_compose_command(self) -> list[str]:

@cached_property
def compose_command_property(self) -> list[str]:
docker_compose_cmd = [self.docker_command_path or "docker", "compose"]
docker_compose_cmd = [self.docker_command_path] if self.docker_command_path else ["docker", "compose"]
if self.compose_file_name:
for file in self.compose_file_name:
docker_compose_cmd += ["-f", file]
Expand Down
39 changes: 36 additions & 3 deletions core/testcontainers/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from pathlib import Path
from typing import Optional, Union

import docker


class ConnectionMode(Enum):
bridge_ip = "bridge_ip"
Expand All @@ -24,14 +26,32 @@ def use_mapped_port(self) -> bool:
return True


def get_docker_socket() -> str:
"""
Determine the docker socket, prefer value given by env variable

Using the docker api ensure we handle rootless docker properly
"""
if socket_path := environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE"):
return socket_path

client = docker.from_env()
try:
socket_path = client.api.get_adapter(client.api.base_url).socket_path
# return the normalized path as string
return str(Path(socket_path).absolute())
except AttributeError:
return "/var/run/docker.sock"


MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120))
SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1))
TIMEOUT = MAX_TRIES * SLEEP_TIME

RYUK_IMAGE: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.8.1")
RYUK_PRIVILEGED: bool = environ.get("TESTCONTAINERS_RYUK_PRIVILEGED", "false") == "true"
RYUK_DISABLED: bool = environ.get("TESTCONTAINERS_RYUK_DISABLED", "false") == "true"
RYUK_DOCKER_SOCKET: str = environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/var/run/docker.sock")
RYUK_DOCKER_SOCKET: str = get_docker_socket()
RYUK_RECONNECTION_TIMEOUT: str = environ.get("RYUK_RECONNECTION_TIMEOUT", "10s")
TC_HOST_OVERRIDE: Optional[str] = environ.get("TC_HOST", environ.get("TESTCONTAINERS_HOST_OVERRIDE"))

Expand Down Expand Up @@ -71,7 +91,10 @@ def read_tc_properties() -> dict[str, str]:
return settings


_WARNINGS = {"DOCKER_AUTH_CONFIG": "DOCKER_AUTH_CONFIG is experimental, see testcontainers/testcontainers-python#566"}
_WARNINGS = {
"DOCKER_AUTH_CONFIG": "DOCKER_AUTH_CONFIG is experimental, see testcontainers/testcontainers-python#566",
"tc_properties_get_tc_host": "this method has moved to property 'tc_properties_tc_host'",
}


@dataclass
Expand All @@ -86,7 +109,7 @@ class TestcontainersConfiguration:
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
_docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG"))
tc_host_override: Optional[str] = TC_HOST_OVERRIDE
connection_mode_override: Optional[ConnectionMode] = None
connection_mode_override: Optional[ConnectionMode] = field(default_factory=get_user_overwritten_connection_mode)

"""
https://github.com/testcontainers/testcontainers-go/blob/dd76d1e39c654433a3d80429690d07abcec04424/docker.go#L644
Expand All @@ -107,9 +130,19 @@ def docker_auth_config(self, value: str) -> None:
self._docker_auth_config = value

def tc_properties_get_tc_host(self) -> Union[str, None]:
if "tc_properties_get_tc_host" in _WARNINGS:
warning(_WARNINGS.pop("tc_properties_get_tc_host"))
return self.tc_properties.get("tc.host")

@property
def tc_properties_tc_host(self) -> Union[str, None]:
return self.tc_properties.get("tc.host")

@property
def tc_properties_testcontainers_reuse_enable(self) -> bool:
enabled = self.tc_properties.get("testcontainers.reuse.enable")
return enabled == "true"

def timeout(self) -> int:
return self.max_tries * self.sleep_time

Expand Down
57 changes: 54 additions & 3 deletions core/testcontainers/core/container.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import contextlib
import hashlib
import logging
from platform import system
from os import PathLike
from socket import socket
from typing import TYPE_CHECKING, Optional, Union
Expand Down Expand Up @@ -53,6 +56,7 @@ def __init__(
self._name = None
self._network: Optional[Network] = None
self._network_aliases: Optional[list[str]] = None
self._reuse: bool = False
self._kwargs = kwargs

def with_env(self, key: str, value: str) -> Self:
Expand Down Expand Up @@ -86,17 +90,24 @@ def with_kwargs(self, **kwargs) -> Self:
self._kwargs = kwargs
return self

def with_reuse(self, reuse=True) -> Self:
self._reuse = reuse
return self

def maybe_emulate_amd64(self) -> Self:
if is_arm():
return self.with_kwargs(platform="linux/amd64")
return self

def start(self) -> Self:
if not c.ryuk_disabled and self.image != c.ryuk_image:
if (
not c.ryuk_disabled
and self.image != c.ryuk_image
and not (self._reuse and c.tc_properties_testcontainers_reuse_enable)
):
logger.debug("Creating Ryuk container")
Reaper.get_instance()
logger.info("Pulling image %s", self.image)
docker_client = self.get_docker_client()
self._configure()

network_kwargs = (
Expand All @@ -110,6 +121,45 @@ def start(self) -> Self:
else {}
)

if self._reuse and not c.tc_properties_testcontainers_reuse_enable:
logging.warning(
"Reuse was requested (`with_reuse`) but the environment does not "
+ "support the reuse of containers. To enable container reuse, add "
+ "'testcontainers.reuse.enable=true' to '~/.testcontainers.properties'."
)

if self._reuse and c.tc_properties_testcontainers_reuse_enable:
# NOTE: ideally the docker client would return the full container create
# request which could be used to generate the hash.
args = [ # Docker run arguments
self.image,
self._command,
self.env,
self.ports,
self._name,
self.volumes,
str(tuple(sorted(self._kwargs.values()))),
]
hash_ = hashlib.sha256(bytes(str(args), encoding="utf-8")).hexdigest()
docker_client = self.get_docker_client()
container = docker_client.find_container_by_hash(hash_)
if container:
if container.status != "running":
container.start()
logger.info("Existing container started: %s", container.id)
self._container = container
logger.info("Container is already running: %s", container.id)
else:
self._start(network_kwargs, hash_)
else:
self._start(network_kwargs)

if self._network:
self._network.connect(self._container.id, self._network_aliases)
return self

def _start(self, network_kwargs, hash_=None):
docker_client = self.get_docker_client()
self._container = docker_client.run(
self.image,
command=self._command,
Expand All @@ -118,6 +168,7 @@ def start(self) -> Self:
ports=self.ports,
name=self._name,
volumes=self.volumes,
labels={"hash": hash_} if hash is not None else {},
**network_kwargs,
**self._kwargs,
)
Expand Down Expand Up @@ -155,7 +206,7 @@ def get_exposed_port(self, port: int) -> int:
return self.get_docker_client().port(self._container.id, port)
return port

def with_command(self, command: str) -> Self:
def with_command(self, command: Union[str, list[str]]) -> Self:
self._command = command
return self

Expand Down
8 changes: 7 additions & 1 deletion core/testcontainers/core/docker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,15 @@ def client_networks_create(self, name: str, param: dict):
labels = create_labels("", param.get("labels"))
return self.client.networks.create(name, **{**param, "labels": labels})

def find_container_by_hash(self, hash_: str) -> Union[Container, None]:
for container in self.client.containers.list(all=True):
if container.labels.get("hash", None) == hash_:
return container
return None


def get_docker_host() -> Optional[str]:
return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
return c.tc_properties_tc_host or os.getenv("DOCKER_HOST")


def get_docker_auth_config() -> Optional[str]:
Expand Down
9 changes: 6 additions & 3 deletions core/testcontainers/core/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ class DockerImage:
>>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-image") as image:
... logs = image.get_logs()

:param tag: Tag for the image to be built (default: None)
:param path: Path to the build context
:param docker_client_kw: Keyword arguments to pass to the DockerClient
:param tag: Tag for the image to be built (default: None)
:param clean_up: Remove the image after exiting the context (default: True)
:param dockerfile_path: Path to the Dockerfile within the build context path (default: Dockerfile)
:param no_cache: Bypass build cache; CLI's --no-cache
:param kwargs: Additional keyword arguments to pass to the underlying docker-py
"""

def __init__(
Expand All @@ -49,11 +52,11 @@ def __init__(
self._dockerfile_path = dockerfile_path
self._no_cache = no_cache

def build(self, **kwargs) -> Self:
def build(self) -> Self:
logger.info(f"Building image from {self.path}")
docker_client = self.get_docker_client()
self._image, self._logs = docker_client.build(
path=str(self.path), tag=self.tag, dockerfile=self._dockerfile_path, nocache=self._no_cache, **kwargs
path=str(self.path), tag=self.tag, dockerfile=self._dockerfile_path, nocache=self._no_cache, **self._kwargs
)
logger.info(f"Built image {self.short_id} with tag {self.tag}")
return self
Expand Down
15 changes: 9 additions & 6 deletions core/testcontainers/core/labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ def create_labels(image: str, labels: Optional[dict[str, str]]) -> dict[str, str
if k.startswith(TESTCONTAINERS_NAMESPACE):
raise ValueError("The org.testcontainers namespace is reserved for internal use")

labels[LABEL_LANG] = "python"
labels[LABEL_TESTCONTAINERS] = "true"
labels[LABEL_VERSION] = importlib.metadata.version("testcontainers")
tc_labels = {
**labels,
LABEL_LANG: "python",
LABEL_TESTCONTAINERS: "true",
LABEL_VERSION: importlib.metadata.version("testcontainers"),
}

if image == c.ryuk_image:
return labels
return tc_labels

labels[LABEL_SESSION_ID] = SESSION_ID
return labels
tc_labels[LABEL_SESSION_ID] = SESSION_ID
return tc_labels
2 changes: 2 additions & 0 deletions core/testcontainers/socat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# flake8: noqa
from testcontainers.socat.socat import SocatContainer
Loading