Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: testcontainers/testcontainers-python
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: testcontainers-v4.4.1
Choose a base ref
...
head repository: testcontainers/testcontainers-python
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: testcontainers-v4.5.0
Choose a head ref
  • 6 commits
  • 16 files changed
  • 5 contributors

Commits on May 14, 2024

  1. test(postgres): add example of initdb.d usage for postgres (#572)

    alexanderankin authored May 14, 2024

    Unverified

    This user has not yet uploaded their public signing key.
    Copy the full SHA
    6f9376c View commit details

Commits on May 17, 2024

  1. fix: added types to exec & tc_properties_get_tc_host (#561)

    #557 -
    trying to solve this issue by adding types.
    
    ---------
    
    Co-authored-by: Dandiggas <dadekugbe@googlemail.com>
    Dandiggas and Dandiggas authored May 17, 2024

    Unverified

    This user has not yet uploaded their public signing key.
    Copy the full SHA
    9eabb79 View commit details

Commits on May 24, 2024

  1. typos (#580)

    alexanderankin authored May 24, 2024
    Copy the full SHA
    9d2ceb6 View commit details

Commits on May 25, 2024

  1. feat(core): Private registry (#566)

    Ref #562
    
    This enhancement adds capability to utilize the env var
    `DOCKER_AUTH_CONFIG` in-order to login to a private docker registry.
    
    ---------
    
    Co-authored-by: David Ankin <daveankin@gmail.com>
    Tranquility2 and alexanderankin authored May 25, 2024
    Copy the full SHA
    59fbcfa View commit details
  2. fix: on windows, DockerCompose.get_service_host returns an unusable "…

    …0.0.0.0" - adjust to 127.0.0.1 (#457)
    
    #358 
    
    not sure if this is the right solution
    alexanderankin authored May 25, 2024
    Copy the full SHA
    2aa3d37 View commit details
  3. chore(main): release testcontainers 4.5.0 (#575)

    🤖 I have created a release *beep* *boop*
    ---
    
    
    ##
    [4.5.0](testcontainers-v4.4.1...testcontainers-v4.5.0)
    (2024-05-25)
    
    
    ### Features
    
    * **core:** Private registry
    ([#566](#566))
    ([59fbcfa](59fbcfa))
    
    
    ### Bug Fixes
    
    * added types to exec & tc_properties_get_tc_host
    ([#561](#561))
    ([9eabb79](9eabb79))
    * on windows, DockerCompose.get_service_host returns an unusable
    "0.0.0.0" - adjust to 127.0.0.1
    ([#457](#457))
    ([2aa3d37](2aa3d37))
    
    ---
    This PR was generated with [Release
    Please](https://github.com/googleapis/release-please). See
    [documentation](https://github.com/googleapis/release-please#release-please).
    
    Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
    github-actions[bot] authored May 25, 2024
    Copy the full SHA
    70414db View commit details
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.4.1"
".": "4.5.0"
}
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [4.5.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.4.1...testcontainers-v4.5.0) (2024-05-25)


### Features

* **core:** Private registry ([#566](https://github.com/testcontainers/testcontainers-python/issues/566)) ([59fbcfa](https://github.com/testcontainers/testcontainers-python/commit/59fbcfaf512d1f094e6d8346d45766e810ee2d44))


### Bug Fixes

* added types to exec & tc_properties_get_tc_host ([#561](https://github.com/testcontainers/testcontainers-python/issues/561)) ([9eabb79](https://github.com/testcontainers/testcontainers-python/commit/9eabb79f213cfb6d8e60173ff4c40f580ae0972a))
* on windows, DockerCompose.get_service_host returns an unusable "0.0.0.0" - adjust to 127.0.0.1 ([#457](https://github.com/testcontainers/testcontainers-python/issues/457)) ([2aa3d37](https://github.com/testcontainers/testcontainers-python/commit/2aa3d371647877db45eac1663814dcc99de0f6af))

## [4.4.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.4.0...testcontainers-v4.4.1) (2024-05-14)


17 changes: 13 additions & 4 deletions core/testcontainers/compose/compose.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from dataclasses import dataclass, field, fields
from dataclasses import asdict, dataclass, field, fields
from functools import cached_property
from json import loads
from os import PathLike
from platform import system
from re import split
from subprocess import CompletedProcess
from subprocess import run as subprocess_run
@@ -38,6 +39,14 @@ class PublishedPort:
PublishedPort: Optional[str] = None
Protocol: Optional[str] = None

def normalize(self):
url_not_usable = system() == "Windows" and self.URL == "0.0.0.0"
if url_not_usable:
self_dict = asdict(self)
self_dict.update({"URL": "127.0.0.1"})
return PublishedPort(**self_dict)
return self


OT = TypeVar("OT")

@@ -357,7 +366,7 @@ def get_service_port(
str:
The mapped port on the host
"""
return self.get_container(service_name).get_publisher(by_port=port).PublishedPort
return self.get_container(service_name).get_publisher(by_port=port).normalize().PublishedPort

def get_service_host(
self,
@@ -379,14 +388,14 @@ def get_service_host(
str:
The hostname for the service
"""
return self.get_container(service_name).get_publisher(by_port=port).URL
return self.get_container(service_name).get_publisher(by_port=port).normalize().URL

def get_service_host_and_port(
self,
service_name: Optional[str] = None,
port: Optional[int] = None,
):
publisher = self.get_container(service_name).get_publisher(by_port=port)
publisher = self.get_container(service_name).get_publisher(by_port=port).normalize()
return publisher.URL, publisher.PublishedPort

@wait_container_is_ready(HTTPError, URLError)
20 changes: 19 additions & 1 deletion core/testcontainers/core/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from dataclasses import dataclass, field
from logging import warning
from os import environ
from os.path import exists
from pathlib import Path
from typing import Optional, Union

MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120))
SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1))
@@ -36,6 +38,9 @@ def read_tc_properties() -> dict[str, str]:
return settings


_WARNINGS = {"DOCKER_AUTH_CONFIG": "DOCKER_AUTH_CONFIG is experimental, see testcontainers/testcontainers-python#566"}


@dataclass
class TestcontainersConfiguration:
max_tries: int = MAX_TRIES
@@ -46,8 +51,21 @@ class TestcontainersConfiguration:
ryuk_docker_socket: str = RYUK_DOCKER_SOCKET
ryuk_reconnection_timeout: str = RYUK_RECONNECTION_TIMEOUT
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"))

@property
def docker_auth_config(self):
if "DOCKER_AUTH_CONFIG" in _WARNINGS:
warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG"))
return self._docker_auth_config

@docker_auth_config.setter
def docker_auth_config(self, value: str):
if "DOCKER_AUTH_CONFIG" in _WARNINGS:
warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG"))
self._docker_auth_config = value

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

@property
2 changes: 1 addition & 1 deletion core/testcontainers/core/container.py
Original file line number Diff line number Diff line change
@@ -172,7 +172,7 @@ def get_logs(self) -> tuple[bytes, bytes]:
raise ContainerStartException("Container should be started before getting logs")
return self._container.logs(stderr=False), self._container.logs(stdout=False)

def exec(self, command) -> tuple[int, str]:
def exec(self, command) -> tuple[int, bytes]:
if not self._container:
raise ContainerStartException("Container should be started before executing a command")
return self._container.exec_run(command)
17 changes: 16 additions & 1 deletion core/testcontainers/core/docker_client.py
Original file line number Diff line number Diff line change
@@ -24,7 +24,7 @@

from testcontainers.core.config import testcontainers_config as c
from testcontainers.core.labels import SESSION_ID, create_labels
from testcontainers.core.utils import default_gateway_ip, inside_container, setup_logger
from testcontainers.core.utils import default_gateway_ip, inside_container, parse_docker_auth_config, setup_logger

LOGGER = setup_logger(__name__)

@@ -57,6 +57,9 @@ def __init__(self, **kwargs) -> None:
self.client.api.headers["x-tc-sid"] = SESSION_ID
self.client.api.headers["User-Agent"] = "tc-python/" + importlib.metadata.version("testcontainers")

if docker_auth_config := get_docker_auth_config():
self.login(docker_auth_config)

@_wrapped_container_collection
def run(
self,
@@ -183,6 +186,18 @@ def host(self) -> str:
return ip_address
return "localhost"

def login(self, docker_auth_config: str) -> None:
"""
Login to a docker registry using the given auth config.
"""
auth_config = parse_docker_auth_config(docker_auth_config)[0] # Only using the first auth config
login_info = self.client.login(**auth_config._asdict())
LOGGER.debug(f"logged in using {login_info}")


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


def get_docker_auth_config() -> Optional[str]:
return c.docker_auth_config
2 changes: 1 addition & 1 deletion core/testcontainers/core/labels.py
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ def create_labels(image: str, labels: Optional[dict[str, str]]) -> dict[str, str
else:
for k in labels:
if k.startswith(TESTCONTAINERS_NAMESPACE):
raise ValueError("The org.testcontainers namespace is reserved for interal use")
raise ValueError("The org.testcontainers namespace is reserved for internal use")

labels[LABEL_LANG] = "python"
labels[LABEL_TESTCONTAINERS] = "true"
31 changes: 31 additions & 0 deletions core/testcontainers/core/utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import base64
import json
import logging
import os
import platform
import subprocess
import sys
from collections import namedtuple

LINUX = "linux"
MAC = "mac"
WIN = "win"

DockerAuthInfo = namedtuple("DockerAuthInfo", ["registry", "username", "password"])


def setup_logger(name: str) -> logging.Logger:
logger = logging.getLogger(name)
@@ -77,3 +82,29 @@ def raise_for_deprecated_parameter(kwargs: dict, name: str, replacement: str) ->
if kwargs.pop(name, None):
raise ValueError(f"Use `{replacement}` instead of `{name}`")
return kwargs


def parse_docker_auth_config(auth_config: str) -> list[DockerAuthInfo]:
"""
Parse the docker auth config from a string.
Example:
{
"auths": {
"https://index.docker.io/v1/": {
"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="
}
}
}
"""
auth_info: list[DockerAuthInfo] = []
try:
auth_config_dict: dict = json.loads(auth_config).get("auths")
for registry, auth in auth_config_dict.items():
auth_str = auth.get("auth")
auth_str = base64.b64decode(auth_str).decode("utf-8")
username, password = auth_str.split(":")
auth_info.append(DockerAuthInfo(registry, username, password))
return auth_info
except (json.JSONDecodeError, KeyError, ValueError) as exp:
raise ValueError("Could not parse docker auth config") from exp
32 changes: 32 additions & 0 deletions core/tests/test_docker_client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import os
from collections import namedtuple
from unittest import mock
from unittest.mock import MagicMock, patch

import docker

from testcontainers.core.config import testcontainers_config as c
from testcontainers.core.container import DockerContainer
from testcontainers.core.docker_client import DockerClient
from testcontainers.core.utils import parse_docker_auth_config


def test_docker_client_from_env():
@@ -15,6 +20,33 @@ def test_docker_client_from_env():
mock_docker.from_env.assert_called_with(**test_kwargs)


def test_docker_client_login_no_login():
with patch.dict(os.environ, {}, clear=True):
mock_docker = MagicMock(spec=docker)
with patch("testcontainers.core.docker_client.docker", mock_docker):
DockerClient()

mock_docker.from_env.return_value.login.assert_not_called()


def test_docker_client_login():
mock_docker = MagicMock(spec=docker)
mock_parse_docker_auth_config = MagicMock(spec=parse_docker_auth_config)
mock_utils = MagicMock()
mock_utils.parse_docker_auth_config = mock_parse_docker_auth_config
TestAuth = namedtuple("Auth", "value")
mock_parse_docker_auth_config.return_value = [TestAuth("test")]

with (
mock.patch.object(c, "_docker_auth_config", "test"),
patch("testcontainers.core.docker_client.docker", mock_docker),
patch("testcontainers.core.docker_client.parse_docker_auth_config", mock_parse_docker_auth_config),
):
DockerClient()

mock_docker.from_env.return_value.login.assert_called_with(**{"value": "test"})


def test_container_docker_client_kw():
test_kwargs = {"test_kw": "test_value"}
mock_docker = MagicMock(spec=docker)
42 changes: 42 additions & 0 deletions core/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import json

from testcontainers.core.utils import parse_docker_auth_config, DockerAuthInfo


def test_parse_docker_auth_config():
auth_config_json = '{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcm5hbWU6cGFzc3dvcmQ="}}}'
auth_info = parse_docker_auth_config(auth_config_json)
assert len(auth_info) == 1
assert auth_info[0] == DockerAuthInfo(
registry="https://index.docker.io/v1/",
username="username",
password="password",
)


def test_parse_docker_auth_config_multiple():
auth_dict = {
"auths": {
"localhost:5000": {"auth": "dXNlcjE6cGFzczE=="},
"https://example.com": {"auth": "dXNlcl9uZXc6cGFzc19uZXc=="},
"example2.com": {"auth": "YWJjOjEyMw==="},
}
}
auth_config_json = json.dumps(auth_dict)
auth_info = parse_docker_auth_config(auth_config_json)
assert len(auth_info) == 3
assert auth_info[0] == DockerAuthInfo(
registry="localhost:5000",
username="user1",
password="pass1",
)
assert auth_info[1] == DockerAuthInfo(
registry="https://example.com",
username="user_new",
password="pass_new",
)
assert auth_info[2] == DockerAuthInfo(
registry="example2.com",
username="abc",
password="123",
)
10 changes: 5 additions & 5 deletions modules/arangodb/tests/test_arangodb.py
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@
IMAGE_VERSION = "3.11.8"


def arango_test_ops(arango_client, expeced_version, username="root", password=""):
def arango_test_ops(arango_client, expected_version, username="root", password=""):
"""
Basic ArangoDB operations to test DB really up and running.
"""
@@ -22,7 +22,7 @@ def arango_test_ops(arango_client, expeced_version, username="root", password=""
# Taken from https://github.com/ArangoDB-Community/python-arango/blob/main/README.md
# Connect to "_system" database as root user.
sys_db = arango_client.db("_system", username=username, password=password)
assert sys_db.version() == expeced_version
assert sys_db.version() == expected_version

# Create a new database named "test".
sys_db.create_database("test")
@@ -63,7 +63,7 @@ def test_docker_run_arango():
with pytest.raises(DatabaseCreateError):
sys_db.create_database("test")

arango_test_ops(arango_client=client, expeced_version=IMAGE_VERSION, password=arango_root_password)
arango_test_ops(arango_client=client, expected_version=IMAGE_VERSION, password=arango_root_password)


def test_docker_run_arango_without_auth():
@@ -75,7 +75,7 @@ def test_docker_run_arango_without_auth():
with ArangoDbContainer(image, arango_no_auth=True) as arango:
client = ArangoClient(hosts=arango.get_connection_url())

arango_test_ops(arango_client=client, expeced_version=IMAGE_VERSION, password="")
arango_test_ops(arango_client=client, expected_version=IMAGE_VERSION, password="")


@pytest.mark.skipif(platform.processor() == "arm", reason="Test does not run on machines with ARM CPU")
@@ -94,7 +94,7 @@ def test_docker_run_arango_older_version():
with ArangoDbContainer(image, arango_no_auth=True) as arango:
client = ArangoClient(hosts=arango.get_connection_url())

arango_test_ops(arango_client=client, expeced_version=image_version, password="")
arango_test_ops(arango_client=client, expected_version=image_version, password="")


def test_docker_run_arango_random_root_password():
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
create table example
(
id serial not null primary key,
name varchar(255) not null unique,
description text null
);
20 changes: 20 additions & 0 deletions modules/postgres/tests/test_postgres.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

import pytest

from testcontainers.postgres import PostgresContainer
@@ -77,3 +79,21 @@ def test_quoted_password():
# it raises ValueError, but auth (OperationalError) = more interesting
with sqlalchemy.create_engine(raw_pass_url).begin() as connection:
connection.execute(sqlalchemy.text("select 1=1"))


def test_show_how_to_initialize_db_via_initdb_dir():
postgres_container = PostgresContainer("postgres:16-alpine")
script = Path(__file__).parent / "fixtures" / "postgres_create_example_table.sql"
postgres_container.with_volume_mapping(host=str(script), container=f"/docker-entrypoint-initdb.d/{script.name}")

insert_query = "insert into example(name, description) VALUES ('sally', 'sells seashells');"
select_query = "select id, name, description from example;"

with postgres_container as postgres:
engine = sqlalchemy.create_engine(postgres.get_connection_url())
with engine.begin() as connection:
connection.execute(sqlalchemy.text(insert_query))
result = connection.execute(sqlalchemy.text(select_query))
result = result.fetchall()
assert len(result) == 1
assert result[0] == (1, "sally", "sells seashells")
Loading