Skip to content

Commit 6de6674

Browse files
Merge branch 'main' into feature/857
2 parents 6f2fdab + c785ecd commit 6de6674

File tree

14 files changed

+1615
-1084
lines changed

14 files changed

+1615
-1084
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "4.13.2"
2+
".": "4.13.3"
33
}

.github/actions/setup-env/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: set up the python environment
44
inputs:
55
python-version:
66
description: "The python version to install and use"
7-
default: "3.12" # we default to latest supported
7+
default: "3.14" # we default to latest supported
88
required: false
99

1010
runs:

.github/workflows/ci-community.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Contrinuous Integration for community modules
1+
# Continuous Integration for community modules
22

33
name: modules
44

@@ -44,7 +44,7 @@ jobs:
4444
strategy:
4545
fail-fast: false
4646
matrix:
47-
python-version: ["3.9", "3.10", "3.11", "3.12"]
47+
python-version: ["3.9", "3.13", "3.14"]
4848
module: ${{ fromJSON(needs.track-modules.outputs.changed_modules) }}
4949
steps:
5050
- name: Checkout contents

.github/workflows/ci-core.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
strategy:
1515
fail-fast: false
1616
matrix:
17-
python-version: ["3.9", "3.11", "3.12", "3.13"]
17+
python-version: ["3.9", "3.13", "3.14"]
1818
steps:
1919
- uses: actions/checkout@v4
2020
- name: Set up Python
@@ -25,8 +25,6 @@ jobs:
2525
run: poetry install --all-extras
2626
- name: Run twine check
2727
run: rm -f LICENSE.txt && poetry build && poetry run twine check dist/*.tar.gz
28-
- name: Set up Docker
29-
uses: docker/setup-docker-action@v4
3028
- name: Run tests
3129
run: make core/tests
3230
- name: Rename coverage file

.github/workflows/ci-lint.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Contrinuous Integration for the core package
1+
# Continuous Integration for the core package
22

33
name: lint
44

@@ -16,7 +16,7 @@ jobs:
1616
- name: Setup Env
1717
uses: ./.github/actions/setup-env
1818
with:
19-
python-version: "3.9" # the pre-commit is hooked in as 3.9
19+
python-version: "3.13"
2020
- name: Install Python dependencies
2121
run: poetry install --no-interaction
2222
- name: Execute pre-commit handler

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- name: Set up Python
1515
uses: ./.github/actions/setup-env
1616
with:
17-
python-version: "3.11"
17+
python-version: "3.13"
1818
- name: Install Python dependencies
1919
run: poetry install --all-extras
2020
- name: Build documentation

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [4.13.3](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.13.2...testcontainers-v4.13.3) (2025-11-14)
4+
5+
6+
### Bug Fixes
7+
8+
* do not require consumer of library to state nonsupport for py4 ([#912](https://github.com/testcontainers/testcontainers-python/issues/912)) ([f608df9](https://github.com/testcontainers/testcontainers-python/commit/f608df908f87674484b106831d8e8019fdc1927c))
9+
* **docs:** Update dependencies for docs ([#900](https://github.com/testcontainers/testcontainers-python/issues/900)) ([3f66784](https://github.com/testcontainers/testcontainers-python/commit/3f667847a0d9a893e4f15481d81d131817382d5c))
10+
* support python 3.14!!! - ([#917](https://github.com/testcontainers/testcontainers-python/issues/917)) ([f76e982](https://github.com/testcontainers/testcontainers-python/commit/f76e982ca6f40d185d6f430be0a62cd26afbf7e6))
11+
312
## [4.13.2](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.13.1...testcontainers-v4.13.2) (2025-10-07)
413

514

core/testcontainers/core/wait_strategies.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- HealthcheckWaitStrategy: Wait for Docker health checks to pass
77
- PortWaitStrategy: Wait for TCP ports to be available
88
- FileExistsWaitStrategy: Wait for files to exist on the filesystem
9+
- ExecWaitStrategy: Wait for command execution inside container to succeed
910
- CompositeWaitStrategy: Combine multiple wait strategies
1011
1112
Example:
@@ -19,6 +20,9 @@
1920
# Wait for log message
2021
container.waiting_for(LogMessageWaitStrategy("Server started"))
2122
23+
# Wait for command execution
24+
container.waiting_for(ExecWaitStrategy(["pg_isready", "-U", "postgres"]))
25+
2226
# Combine multiple strategies
2327
container.waiting_for(CompositeWaitStrategy(
2428
LogMessageWaitStrategy("Database ready"),
@@ -779,9 +783,103 @@ def wait_until_ready(self, container: WaitStrategyTarget) -> None:
779783
logger.debug("CompositeWaitStrategy: All strategies completed successfully")
780784

781785

786+
class ExecWaitStrategy(WaitStrategy):
787+
"""
788+
Wait for a command execution inside the container to succeed.
789+
790+
This strategy executes a command inside the container and waits for it to
791+
return a successful exit code. It's useful for databases and services
792+
that provide CLI tools to check readiness.
793+
794+
Args:
795+
command: Command to execute (list of strings or single string)
796+
expected_exit_code: Expected exit code for success (default: 0)
797+
798+
Example:
799+
# Wait for Postgres readiness
800+
strategy = ExecWaitStrategy(
801+
["sh", "-c",
802+
"PGPASSWORD='password' psql -U user -d db -h 127.0.0.1 -c 'select 1;'"]
803+
)
804+
805+
# Wait for Redis readiness
806+
strategy = ExecWaitStrategy(["redis-cli", "ping"])
807+
808+
# Check for specific exit code
809+
strategy = ExecWaitStrategy(["custom-healthcheck.sh"], expected_exit_code=0)
810+
"""
811+
812+
def __init__(
813+
self,
814+
command: Union[str, list[str]],
815+
expected_exit_code: int = 0,
816+
) -> None:
817+
super().__init__()
818+
self._command = command if isinstance(command, list) else [command]
819+
self._expected_exit_code = expected_exit_code
820+
821+
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
822+
"""
823+
Wait until command execution succeeds with the expected exit code.
824+
825+
Args:
826+
container: The container to execute commands in
827+
828+
Raises:
829+
TimeoutError: If the command doesn't succeed within the timeout period
830+
RuntimeError: If the container doesn't support exec
831+
"""
832+
# Check if container supports exec (DockerContainer does, ComposeContainer doesn't)
833+
if not hasattr(container, "exec"):
834+
raise RuntimeError(
835+
f"ExecWaitStrategy requires a container with exec support. "
836+
f"Container type {type(container).__name__} does not support exec."
837+
)
838+
839+
start_time = time.time()
840+
last_exit_code = None
841+
last_output = None
842+
843+
while True:
844+
duration = time.time() - start_time
845+
if duration > self._startup_timeout:
846+
command_str = " ".join(self._command)
847+
raise TimeoutError(
848+
f"Command execution did not succeed within {self._startup_timeout:.3f} seconds. "
849+
f"Command: {command_str}. "
850+
f"Expected exit code: {self._expected_exit_code}, "
851+
f"last exit code: {last_exit_code}. "
852+
f"Last output: {last_output}. "
853+
f"Hint: Check if the service is starting correctly, the command is valid, "
854+
f"and all required environment variables or credentials are properly configured."
855+
)
856+
857+
try:
858+
result = container.exec(self._command)
859+
last_exit_code = result.exit_code
860+
last_output = result.output.decode() if hasattr(result.output, "decode") else str(result.output)
861+
862+
if result.exit_code == self._expected_exit_code:
863+
logger.debug(
864+
f"ExecWaitStrategy: Command succeeded with exit code {result.exit_code} after {duration:.2f}s"
865+
)
866+
return
867+
868+
logger.debug(
869+
f"ExecWaitStrategy: Command failed with exit code {result.exit_code}, "
870+
f"expected {self._expected_exit_code}. Retrying..."
871+
)
872+
except Exception as e:
873+
logger.debug(f"ExecWaitStrategy: Command execution failed with exception: {e}. Retrying...")
874+
last_output = str(e)
875+
876+
time.sleep(self._poll_interval)
877+
878+
782879
__all__ = [
783880
"CompositeWaitStrategy",
784881
"ContainerStatusWaitStrategy",
882+
"ExecWaitStrategy",
785883
"FileExistsWaitStrategy",
786884
"HealthcheckWaitStrategy",
787885
"HttpWaitStrategy",

modules/elasticsearch/testcontainers/elasticsearch/__init__.py

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,10 @@
1212
# under the License.
1313
import logging
1414
import re
15-
import urllib
16-
from urllib.error import URLError
1715

1816
from testcontainers.core.container import DockerContainer
1917
from testcontainers.core.utils import raise_for_deprecated_parameter
20-
from testcontainers.core.waiting_utils import wait_container_is_ready
18+
from testcontainers.core.wait_strategies import HttpWaitStrategy
2119

2220
_FALLBACK_VERSION = 8
2321
"""This version is used when no version could be detected from the image name."""
@@ -69,14 +67,14 @@ class ElasticSearchContainer(DockerContainer):
6967
>>> from testcontainers.elasticsearch import ElasticSearchContainer
7068
7169
>>> with ElasticSearchContainer(f'elasticsearch:8.3.3', mem_limit='3G') as es:
72-
... resp = urllib.request.urlopen(es.get_url())
70+
... resp = urllib.request.urlopen(f'http://{es.get_container_host_ip()}:{es.get_exposed_port(es.port)}')
7371
... json.loads(resp.read().decode())['version']['number']
7472
'8.3.3'
7573
"""
7674

7775
def __init__(self, image: str = "elasticsearch", port: int = 9200, **kwargs) -> None:
7876
raise_for_deprecated_parameter(kwargs, "port_to_expose", "port")
79-
super().__init__(image, **kwargs)
77+
super().__init__(image, _wait_strategy=HttpWaitStrategy(port), **kwargs)
8078
self.port = port
8179
self.with_exposed_ports(self.port)
8280
self.with_env("transport.host", "127.0.0.1")
@@ -85,19 +83,3 @@ def __init__(self, image: str = "elasticsearch", port: int = 9200, **kwargs) ->
8583
major_version = _major_version_from_image_name(image)
8684
for key, value in _environment_by_version(major_version).items():
8785
self.with_env(key, value)
88-
89-
@wait_container_is_ready(URLError)
90-
def _connect(self) -> None:
91-
res = urllib.request.urlopen(self.get_url())
92-
if res.status != 200:
93-
raise Exception()
94-
95-
def get_url(self) -> str:
96-
host = self.get_container_host_ip()
97-
port = self.get_exposed_port(self.port)
98-
return f"http://{host}:{port}"
99-
100-
def start(self) -> "ElasticSearchContainer":
101-
super().start()
102-
self._connect()
103-
return self

modules/elasticsearch/tests/test_elasticsearch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@
1010
@pytest.mark.parametrize("version", ["7.17.18", "8.12.2"])
1111
def test_docker_run_elasticsearch(version):
1212
with ElasticSearchContainer(f"elasticsearch:{version}", mem_limit="3G") as es:
13-
resp = urllib.request.urlopen(es.get_url())
13+
resp = urllib.request.urlopen(f"http://{es.get_container_host_ip()}:{es.get_exposed_port(es.port)}")
1414
assert json.loads(resp.read().decode())["version"]["number"] == version

0 commit comments

Comments
 (0)