pytest-testcontainers 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_testcontainers/__init__.py +37 -0
- pytest_testcontainers/_internal/__init__.py +1 -0
- pytest_testcontainers/_internal/clean_session_admin.py +329 -0
- pytest_testcontainers/_internal/conn_info.py +29 -0
- pytest_testcontainers/_internal/docker_health.py +59 -0
- pytest_testcontainers/_internal/port_resolver.py +20 -0
- pytest_testcontainers/containers.py +181 -0
- pytest_testcontainers/errors.py +61 -0
- pytest_testcontainers/fixtures.py +295 -0
- pytest_testcontainers/makers.py +312 -0
- pytest_testcontainers/plugin.py +104 -0
- pytest_testcontainers/reuse.py +209 -0
- pytest_testcontainers-0.1.0.dist-info/METADATA +598 -0
- pytest_testcontainers-0.1.0.dist-info/RECORD +17 -0
- pytest_testcontainers-0.1.0.dist-info/WHEEL +4 -0
- pytest_testcontainers-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_testcontainers-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Plugin exception hierarchy.
|
|
2
|
+
|
|
3
|
+
User-facing daemon/reuse/start failures are converted to
|
|
4
|
+
``pytest.UsageError`` at maker boundaries (see ``makers.py``).
|
|
5
|
+
``CleanSessionFixtureError`` is the only one preserved as-is for the
|
|
6
|
+
user's test report — it is a test-time failure, not a setup
|
|
7
|
+
misconfiguration.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PytestTestcontainersError(RuntimeError):
|
|
14
|
+
"""Base exception. Plugin-internal; user code rarely sees this."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DockerNotRunningError(PytestTestcontainersError):
|
|
18
|
+
"""``docker.from_env().ping()`` failed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ContainerStartError(PytestTestcontainersError):
|
|
22
|
+
"""A container failed ``.start()``. Wraps the testcontainers exc."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReuseConflictError(PytestTestcontainersError):
|
|
26
|
+
"""An existing reused container exists but cannot be brought back to running.
|
|
27
|
+
|
|
28
|
+
Typical cause: another process now holds the host port the container
|
|
29
|
+
was previously bound to.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CleanSessionFixtureError(PytestTestcontainersError):
|
|
34
|
+
"""A clean-session fixture failed to issue an admin command.
|
|
35
|
+
|
|
36
|
+
Raised by ``tc_psql_db`` / ``tc_mysql_db`` / ``tc_mongo_db`` /
|
|
37
|
+
``tc_redis_clean`` from BOTH execution paths (Python client and
|
|
38
|
+
``docker exec``) so user code sees a single exception type.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
message: str,
|
|
44
|
+
*,
|
|
45
|
+
command: str,
|
|
46
|
+
stderr: str,
|
|
47
|
+
exit_code: int | None,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.command = command
|
|
51
|
+
self.stderr = stderr
|
|
52
|
+
self.exit_code = exit_code
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
base = super().__str__()
|
|
56
|
+
parts = [base, f"command={self.command!r}"]
|
|
57
|
+
if self.exit_code is not None:
|
|
58
|
+
parts.append(f"exit_code={self.exit_code}")
|
|
59
|
+
if self.stderr:
|
|
60
|
+
parts.append(f"stderr={self.stderr!r}")
|
|
61
|
+
return " | ".join(parts)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Pytest fixtures registered by the plugin.
|
|
2
|
+
|
|
3
|
+
Three families per service:
|
|
4
|
+
|
|
5
|
+
1. **Default session** (``tc_<svc>``) — one container per worker session.
|
|
6
|
+
2. **Function-scoped** (``tc_<svc>_func``) — one container per test.
|
|
7
|
+
3. **Clean-session** (``tc_<svc>_db`` / ``tc_redis_clean``) — session
|
|
8
|
+
container, per-test fresh DB/keyspace.
|
|
9
|
+
|
|
10
|
+
Each fixture is replaced with a stub that raises :class:`pytest.UsageError`
|
|
11
|
+
when ``--no-testcontainers`` (or ``PYTEST_TESTCONTAINERS=0``) is active.
|
|
12
|
+
The maker layer is unaffected — it stays usable from non-pytest code.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import secrets
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from pytest_testcontainers._internal.clean_session_admin import (
|
|
23
|
+
create_mysql_db,
|
|
24
|
+
create_postgres_db,
|
|
25
|
+
drop_mongo_db,
|
|
26
|
+
drop_mysql_db,
|
|
27
|
+
drop_postgres_db,
|
|
28
|
+
flush_redis,
|
|
29
|
+
)
|
|
30
|
+
from pytest_testcontainers._internal.conn_info import DbConnInfo
|
|
31
|
+
from pytest_testcontainers._internal.port_resolver import normalize_host
|
|
32
|
+
from pytest_testcontainers.errors import (
|
|
33
|
+
CleanSessionFixtureError,
|
|
34
|
+
DockerNotRunningError,
|
|
35
|
+
PytestTestcontainersError,
|
|
36
|
+
)
|
|
37
|
+
from pytest_testcontainers.makers import (
|
|
38
|
+
make_mongo,
|
|
39
|
+
make_mysql,
|
|
40
|
+
make_postgres,
|
|
41
|
+
make_rabbitmq,
|
|
42
|
+
make_redis,
|
|
43
|
+
)
|
|
44
|
+
from pytest_testcontainers.reuse import is_plugin_disabled
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from collections.abc import Iterator
|
|
48
|
+
|
|
49
|
+
from testcontainers.mongodb import MongoDbContainer
|
|
50
|
+
from testcontainers.mysql import MySqlContainer
|
|
51
|
+
from testcontainers.postgres import PostgresContainer
|
|
52
|
+
from testcontainers.rabbitmq import RabbitMqContainer
|
|
53
|
+
from testcontainers.redis import RedisContainer
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"tc_mongo",
|
|
57
|
+
"tc_mongo_db",
|
|
58
|
+
"tc_mongo_func",
|
|
59
|
+
"tc_mysql",
|
|
60
|
+
"tc_mysql_db",
|
|
61
|
+
"tc_mysql_func",
|
|
62
|
+
"tc_psql",
|
|
63
|
+
"tc_psql_db",
|
|
64
|
+
"tc_psql_func",
|
|
65
|
+
"tc_rabbitmq",
|
|
66
|
+
"tc_rabbitmq_func",
|
|
67
|
+
"tc_redis",
|
|
68
|
+
"tc_redis_clean",
|
|
69
|
+
"tc_redis_func",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
_DISABLED_MSG = (
|
|
73
|
+
"[pytest-testcontainers] disabled via --no-testcontainers / "
|
|
74
|
+
"PYTEST_TESTCONTAINERS=0; user expected to provide service externally"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_enabled() -> None:
|
|
79
|
+
if is_plugin_disabled():
|
|
80
|
+
raise pytest.UsageError(_DISABLED_MSG)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _to_usage_error(exc: PytestTestcontainersError) -> pytest.UsageError:
|
|
84
|
+
"""Convert plugin-internal exceptions to user-friendly pytest.UsageError."""
|
|
85
|
+
if isinstance(exc, DockerNotRunningError):
|
|
86
|
+
msg = (
|
|
87
|
+
"[pytest-testcontainers] Docker daemon is not reachable.\n"
|
|
88
|
+
"Is Docker Desktop / colima / Rancher Desktop running?\n"
|
|
89
|
+
"\n"
|
|
90
|
+
"Options:\n"
|
|
91
|
+
" - start it and re-run pytest, OR\n"
|
|
92
|
+
" - disable the plugin: --no-testcontainers (or "
|
|
93
|
+
"PYTEST_TESTCONTAINERS=0), OR\n"
|
|
94
|
+
" - point at remote Docker via DOCKER_HOST.\n"
|
|
95
|
+
"\n"
|
|
96
|
+
f"Underlying error: {exc}"
|
|
97
|
+
)
|
|
98
|
+
return pytest.UsageError(msg)
|
|
99
|
+
return pytest.UsageError(f"[pytest-testcontainers] {exc}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
# Postgres
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
@pytest.fixture(scope="session")
|
|
106
|
+
def tc_psql() -> Iterator[PostgresContainer]:
|
|
107
|
+
"""Session-scoped Postgres container (default ``postgres:16``)."""
|
|
108
|
+
_check_enabled()
|
|
109
|
+
try:
|
|
110
|
+
with make_postgres() as pg:
|
|
111
|
+
yield pg
|
|
112
|
+
except PytestTestcontainersError as exc:
|
|
113
|
+
raise _to_usage_error(exc) from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.fixture
|
|
117
|
+
def tc_psql_func() -> Iterator[PostgresContainer]:
|
|
118
|
+
"""Function-scoped fresh Postgres container — almost always wrong, see README."""
|
|
119
|
+
_check_enabled()
|
|
120
|
+
try:
|
|
121
|
+
with make_postgres() as pg:
|
|
122
|
+
yield pg
|
|
123
|
+
except PytestTestcontainersError as exc:
|
|
124
|
+
raise _to_usage_error(exc) from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.fixture
|
|
128
|
+
def tc_psql_db(tc_psql: PostgresContainer) -> Iterator[DbConnInfo]:
|
|
129
|
+
"""Per-test fresh Postgres database on the session container."""
|
|
130
|
+
name = f"test_{secrets.token_hex(6)}"
|
|
131
|
+
create_postgres_db(tc_psql, name)
|
|
132
|
+
try:
|
|
133
|
+
yield DbConnInfo(
|
|
134
|
+
host=normalize_host(tc_psql.get_container_host_ip()),
|
|
135
|
+
port=int(tc_psql.get_exposed_port(5432)),
|
|
136
|
+
username=tc_psql.username,
|
|
137
|
+
password=tc_psql.password,
|
|
138
|
+
database=name,
|
|
139
|
+
)
|
|
140
|
+
finally:
|
|
141
|
+
try:
|
|
142
|
+
drop_postgres_db(tc_psql, name)
|
|
143
|
+
except CleanSessionFixtureError:
|
|
144
|
+
import logging
|
|
145
|
+
|
|
146
|
+
logging.getLogger("pytest_testcontainers").exception(
|
|
147
|
+
"tc_psql_db: DROP DATABASE %r failed during teardown", name
|
|
148
|
+
)
|
|
149
|
+
raise
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --------------------------------------------------------------------------
|
|
153
|
+
# Redis
|
|
154
|
+
# --------------------------------------------------------------------------
|
|
155
|
+
@pytest.fixture(scope="session")
|
|
156
|
+
def tc_redis() -> Iterator[RedisContainer]:
|
|
157
|
+
_check_enabled()
|
|
158
|
+
try:
|
|
159
|
+
with make_redis() as r:
|
|
160
|
+
yield r
|
|
161
|
+
except PytestTestcontainersError as exc:
|
|
162
|
+
raise _to_usage_error(exc) from exc
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.fixture
|
|
166
|
+
def tc_redis_func() -> Iterator[RedisContainer]:
|
|
167
|
+
_check_enabled()
|
|
168
|
+
try:
|
|
169
|
+
with make_redis() as r:
|
|
170
|
+
yield r
|
|
171
|
+
except PytestTestcontainersError as exc:
|
|
172
|
+
raise _to_usage_error(exc) from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.fixture
|
|
176
|
+
def tc_redis_clean(tc_redis: RedisContainer) -> Iterator[RedisContainer]:
|
|
177
|
+
"""Session Redis container with ``FLUSHALL`` between tests."""
|
|
178
|
+
yield tc_redis
|
|
179
|
+
flush_redis(tc_redis)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --------------------------------------------------------------------------
|
|
183
|
+
# MySQL
|
|
184
|
+
# --------------------------------------------------------------------------
|
|
185
|
+
@pytest.fixture(scope="session")
|
|
186
|
+
def tc_mysql() -> Iterator[MySqlContainer]:
|
|
187
|
+
_check_enabled()
|
|
188
|
+
try:
|
|
189
|
+
with make_mysql() as my:
|
|
190
|
+
yield my
|
|
191
|
+
except PytestTestcontainersError as exc:
|
|
192
|
+
raise _to_usage_error(exc) from exc
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.fixture
|
|
196
|
+
def tc_mysql_func() -> Iterator[MySqlContainer]:
|
|
197
|
+
_check_enabled()
|
|
198
|
+
try:
|
|
199
|
+
with make_mysql() as my:
|
|
200
|
+
yield my
|
|
201
|
+
except PytestTestcontainersError as exc:
|
|
202
|
+
raise _to_usage_error(exc) from exc
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.fixture
|
|
206
|
+
def tc_mysql_db(tc_mysql: MySqlContainer) -> Iterator[DbConnInfo]:
|
|
207
|
+
name = f"test_{secrets.token_hex(6)}"
|
|
208
|
+
create_mysql_db(tc_mysql, name)
|
|
209
|
+
try:
|
|
210
|
+
yield DbConnInfo(
|
|
211
|
+
host=normalize_host(tc_mysql.get_container_host_ip()),
|
|
212
|
+
port=int(tc_mysql.get_exposed_port(3306)),
|
|
213
|
+
username=tc_mysql.username,
|
|
214
|
+
password=tc_mysql.password,
|
|
215
|
+
database=name,
|
|
216
|
+
)
|
|
217
|
+
finally:
|
|
218
|
+
try:
|
|
219
|
+
drop_mysql_db(tc_mysql, name)
|
|
220
|
+
except CleanSessionFixtureError:
|
|
221
|
+
import logging
|
|
222
|
+
|
|
223
|
+
logging.getLogger("pytest_testcontainers").exception(
|
|
224
|
+
"tc_mysql_db: DROP DATABASE %r failed during teardown", name
|
|
225
|
+
)
|
|
226
|
+
raise
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --------------------------------------------------------------------------
|
|
230
|
+
# MongoDB
|
|
231
|
+
# --------------------------------------------------------------------------
|
|
232
|
+
@pytest.fixture(scope="session")
|
|
233
|
+
def tc_mongo() -> Iterator[MongoDbContainer]:
|
|
234
|
+
_check_enabled()
|
|
235
|
+
try:
|
|
236
|
+
with make_mongo() as m:
|
|
237
|
+
yield m
|
|
238
|
+
except PytestTestcontainersError as exc:
|
|
239
|
+
raise _to_usage_error(exc) from exc
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.fixture
|
|
243
|
+
def tc_mongo_func() -> Iterator[MongoDbContainer]:
|
|
244
|
+
_check_enabled()
|
|
245
|
+
try:
|
|
246
|
+
with make_mongo() as m:
|
|
247
|
+
yield m
|
|
248
|
+
except PytestTestcontainersError as exc:
|
|
249
|
+
raise _to_usage_error(exc) from exc
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@pytest.fixture
|
|
253
|
+
def tc_mongo_db(tc_mongo: MongoDbContainer) -> Iterator[DbConnInfo]:
|
|
254
|
+
name = f"test_{secrets.token_hex(6)}"
|
|
255
|
+
try:
|
|
256
|
+
yield DbConnInfo(
|
|
257
|
+
host=normalize_host(tc_mongo.get_container_host_ip()),
|
|
258
|
+
port=int(tc_mongo.get_exposed_port(27017)),
|
|
259
|
+
username=tc_mongo.username,
|
|
260
|
+
password=tc_mongo.password,
|
|
261
|
+
database=name,
|
|
262
|
+
)
|
|
263
|
+
finally:
|
|
264
|
+
try:
|
|
265
|
+
drop_mongo_db(tc_mongo, name)
|
|
266
|
+
except CleanSessionFixtureError:
|
|
267
|
+
import logging
|
|
268
|
+
|
|
269
|
+
logging.getLogger("pytest_testcontainers").exception(
|
|
270
|
+
"tc_mongo_db: dropDatabase %r failed during teardown", name
|
|
271
|
+
)
|
|
272
|
+
raise
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --------------------------------------------------------------------------
|
|
276
|
+
# RabbitMQ — no clean-session fixture (cleanup not obvious; user wires their own)
|
|
277
|
+
# --------------------------------------------------------------------------
|
|
278
|
+
@pytest.fixture(scope="session")
|
|
279
|
+
def tc_rabbitmq() -> Iterator[RabbitMqContainer]:
|
|
280
|
+
_check_enabled()
|
|
281
|
+
try:
|
|
282
|
+
with make_rabbitmq() as rmq:
|
|
283
|
+
yield rmq
|
|
284
|
+
except PytestTestcontainersError as exc:
|
|
285
|
+
raise _to_usage_error(exc) from exc
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@pytest.fixture
|
|
289
|
+
def tc_rabbitmq_func() -> Iterator[RabbitMqContainer]:
|
|
290
|
+
_check_enabled()
|
|
291
|
+
try:
|
|
292
|
+
with make_rabbitmq() as rmq:
|
|
293
|
+
yield rmq
|
|
294
|
+
except PytestTestcontainersError as exc:
|
|
295
|
+
raise _to_usage_error(exc) from exc
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Public maker functions: ``make_postgres``, ``make_redis``, … .
|
|
2
|
+
|
|
3
|
+
Each maker is a context manager wrapping the corresponding
|
|
4
|
+
``testcontainers-python`` class. The maker layer holds:
|
|
5
|
+
|
|
6
|
+
* daemon ping (cached on success);
|
|
7
|
+
* reuse-name resolution + Ryuk disable + lookup-by-name choreography;
|
|
8
|
+
* atexit safety net for one-shot (function-scoped) mode.
|
|
9
|
+
|
|
10
|
+
The fixture layer (``fixtures.py``) is deliberately thin — it just calls
|
|
11
|
+
these makers from inside ``@pytest.fixture`` functions.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from pytest_testcontainers._internal.docker_health import check_docker_daemon
|
|
20
|
+
from pytest_testcontainers.containers import (
|
|
21
|
+
bind_to_running,
|
|
22
|
+
deregister_atexit_stop,
|
|
23
|
+
disable_ryuk_once,
|
|
24
|
+
find_existing_container,
|
|
25
|
+
register_atexit_stop,
|
|
26
|
+
restart_stopped_or_raise,
|
|
27
|
+
start_or_raise,
|
|
28
|
+
)
|
|
29
|
+
from pytest_testcontainers.reuse import (
|
|
30
|
+
is_reuse_enabled,
|
|
31
|
+
reuse_name_for,
|
|
32
|
+
sanitize_component,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Iterator
|
|
37
|
+
|
|
38
|
+
from testcontainers.core.container import DockerContainer
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------
|
|
42
|
+
# Generic core
|
|
43
|
+
# --------------------------------------------------------------------------
|
|
44
|
+
def _service_slug_for(container_cls: type) -> str:
|
|
45
|
+
"""Derive a service slug from ``Cls.__name__`` (§16 #5)."""
|
|
46
|
+
name = container_cls.__name__.lower()
|
|
47
|
+
if name.endswith("container"):
|
|
48
|
+
name = name[: -len("container")]
|
|
49
|
+
return sanitize_component(name) or "container"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _apply_env(container: DockerContainer, env: dict[str, str] | None) -> None:
|
|
53
|
+
if not env:
|
|
54
|
+
return
|
|
55
|
+
for key, value in env.items():
|
|
56
|
+
container.with_env(key, value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@contextmanager
|
|
60
|
+
def _make_generic(
|
|
61
|
+
container_cls: type[DockerContainer],
|
|
62
|
+
*constructor_args: Any,
|
|
63
|
+
service_slug: str,
|
|
64
|
+
reuse_name: str | None,
|
|
65
|
+
env: dict[str, str] | None,
|
|
66
|
+
constructor_kwargs: dict[str, Any] | None = None,
|
|
67
|
+
) -> Iterator[DockerContainer]:
|
|
68
|
+
"""Shared lifecycle for every maker.
|
|
69
|
+
|
|
70
|
+
``service_slug`` is the short slug used in reuse-name composition
|
|
71
|
+
(``psql``, ``redis``, …, or derived from ``Cls.__name__`` for the
|
|
72
|
+
generic ``make_container``).
|
|
73
|
+
"""
|
|
74
|
+
constructor_kwargs = dict(constructor_kwargs or {})
|
|
75
|
+
|
|
76
|
+
check_docker_daemon()
|
|
77
|
+
|
|
78
|
+
reuse_on = is_reuse_enabled() or reuse_name is not None
|
|
79
|
+
name = reuse_name_for(service_slug, override=reuse_name) if reuse_on else None
|
|
80
|
+
|
|
81
|
+
if reuse_on:
|
|
82
|
+
disable_ryuk_once()
|
|
83
|
+
|
|
84
|
+
instance: DockerContainer | None = None
|
|
85
|
+
is_bound_to_existing = False
|
|
86
|
+
|
|
87
|
+
if reuse_on and name is not None:
|
|
88
|
+
existing = find_existing_container(name)
|
|
89
|
+
if existing is not None:
|
|
90
|
+
status = getattr(existing, "status", "")
|
|
91
|
+
if status == "running":
|
|
92
|
+
instance = bind_to_running(
|
|
93
|
+
container_cls, existing, *constructor_args, **constructor_kwargs
|
|
94
|
+
)
|
|
95
|
+
_apply_env(instance, env)
|
|
96
|
+
is_bound_to_existing = True
|
|
97
|
+
elif status in {"exited", "created", "paused"}:
|
|
98
|
+
restart_stopped_or_raise(existing, name)
|
|
99
|
+
instance = bind_to_running(
|
|
100
|
+
container_cls, existing, *constructor_args, **constructor_kwargs
|
|
101
|
+
)
|
|
102
|
+
_apply_env(instance, env)
|
|
103
|
+
is_bound_to_existing = True
|
|
104
|
+
# Any other status (e.g. "removing", "dead") — treat as
|
|
105
|
+
# "not usable", fall through to fresh start.
|
|
106
|
+
|
|
107
|
+
if instance is None:
|
|
108
|
+
instance = container_cls(*constructor_args, **constructor_kwargs)
|
|
109
|
+
if name is not None:
|
|
110
|
+
instance.with_name(name)
|
|
111
|
+
_apply_env(instance, env)
|
|
112
|
+
|
|
113
|
+
image_for_msg = constructor_kwargs.get("image") or (
|
|
114
|
+
constructor_args[0] if constructor_args else container_cls.__name__
|
|
115
|
+
)
|
|
116
|
+
start_or_raise(instance, str(image_for_msg))
|
|
117
|
+
|
|
118
|
+
if not reuse_on:
|
|
119
|
+
register_atexit_stop(instance)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
yield instance
|
|
123
|
+
finally:
|
|
124
|
+
# Reuse mode: leave the container alive for the next run.
|
|
125
|
+
# Bound-to-existing: we never owned it, never stop it.
|
|
126
|
+
# Any exception raised in the with-body propagates either way —
|
|
127
|
+
# we only do cleanup work, never `return` from finally.
|
|
128
|
+
if not (reuse_on or is_bound_to_existing):
|
|
129
|
+
# Deregister the atexit callback before stopping so the
|
|
130
|
+
# safety net doesn't double-fire on a now-removed container.
|
|
131
|
+
deregister_atexit_stop(instance)
|
|
132
|
+
try:
|
|
133
|
+
instance.stop()
|
|
134
|
+
except Exception:
|
|
135
|
+
# Logged + swallowed: cleanup-time failures must not
|
|
136
|
+
# mask the original test failure. Per §4.1.2.
|
|
137
|
+
import logging
|
|
138
|
+
|
|
139
|
+
logging.getLogger("pytest_testcontainers").exception(
|
|
140
|
+
"cleanup: container.stop() failed for %s", service_slug
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --------------------------------------------------------------------------
|
|
145
|
+
# Service-specific makers
|
|
146
|
+
# --------------------------------------------------------------------------
|
|
147
|
+
def make_postgres(
|
|
148
|
+
image: str = "postgres:16",
|
|
149
|
+
*,
|
|
150
|
+
username: str = "test",
|
|
151
|
+
password: str = "test",
|
|
152
|
+
database: str = "test",
|
|
153
|
+
port: int = 5432,
|
|
154
|
+
env: dict[str, str] | None = None,
|
|
155
|
+
reuse_name: str | None = None,
|
|
156
|
+
**kwargs: Any,
|
|
157
|
+
):
|
|
158
|
+
"""Context manager wrapping ``PostgresContainer``."""
|
|
159
|
+
from testcontainers.postgres import PostgresContainer
|
|
160
|
+
|
|
161
|
+
constructor_kwargs: dict[str, Any] = {
|
|
162
|
+
"image": image,
|
|
163
|
+
"username": username,
|
|
164
|
+
"password": password,
|
|
165
|
+
"dbname": database,
|
|
166
|
+
"port": port,
|
|
167
|
+
**kwargs,
|
|
168
|
+
}
|
|
169
|
+
return _make_generic(
|
|
170
|
+
PostgresContainer,
|
|
171
|
+
service_slug="psql",
|
|
172
|
+
reuse_name=reuse_name,
|
|
173
|
+
env=env,
|
|
174
|
+
constructor_kwargs=constructor_kwargs,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def make_redis(
|
|
179
|
+
image: str = "redis:7-alpine",
|
|
180
|
+
*,
|
|
181
|
+
port: int = 6379,
|
|
182
|
+
env: dict[str, str] | None = None,
|
|
183
|
+
reuse_name: str | None = None,
|
|
184
|
+
**kwargs: Any,
|
|
185
|
+
):
|
|
186
|
+
"""Context manager wrapping ``RedisContainer``."""
|
|
187
|
+
from testcontainers.redis import RedisContainer
|
|
188
|
+
|
|
189
|
+
constructor_kwargs: dict[str, Any] = {
|
|
190
|
+
"image": image,
|
|
191
|
+
"port": port,
|
|
192
|
+
**kwargs,
|
|
193
|
+
}
|
|
194
|
+
return _make_generic(
|
|
195
|
+
RedisContainer,
|
|
196
|
+
service_slug="redis",
|
|
197
|
+
reuse_name=reuse_name,
|
|
198
|
+
env=env,
|
|
199
|
+
constructor_kwargs=constructor_kwargs,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def make_mysql(
|
|
204
|
+
image: str = "mysql:8",
|
|
205
|
+
*,
|
|
206
|
+
username: str = "test",
|
|
207
|
+
password: str = "test",
|
|
208
|
+
database: str = "test",
|
|
209
|
+
root_password: str = "test",
|
|
210
|
+
port: int = 3306,
|
|
211
|
+
env: dict[str, str] | None = None,
|
|
212
|
+
reuse_name: str | None = None,
|
|
213
|
+
**kwargs: Any,
|
|
214
|
+
):
|
|
215
|
+
"""Context manager wrapping ``MySqlContainer``."""
|
|
216
|
+
from testcontainers.mysql import MySqlContainer
|
|
217
|
+
|
|
218
|
+
constructor_kwargs: dict[str, Any] = {
|
|
219
|
+
"image": image,
|
|
220
|
+
"username": username,
|
|
221
|
+
"password": password,
|
|
222
|
+
"dbname": database,
|
|
223
|
+
"root_password": root_password,
|
|
224
|
+
"port": port,
|
|
225
|
+
**kwargs,
|
|
226
|
+
}
|
|
227
|
+
return _make_generic(
|
|
228
|
+
MySqlContainer,
|
|
229
|
+
service_slug="mysql",
|
|
230
|
+
reuse_name=reuse_name,
|
|
231
|
+
env=env,
|
|
232
|
+
constructor_kwargs=constructor_kwargs,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def make_mongo(
|
|
237
|
+
image: str = "mongo:7",
|
|
238
|
+
*,
|
|
239
|
+
username: str = "test",
|
|
240
|
+
password: str = "test",
|
|
241
|
+
port: int = 27017,
|
|
242
|
+
env: dict[str, str] | None = None,
|
|
243
|
+
reuse_name: str | None = None,
|
|
244
|
+
**kwargs: Any,
|
|
245
|
+
):
|
|
246
|
+
"""Context manager wrapping ``MongoDbContainer``."""
|
|
247
|
+
from testcontainers.mongodb import MongoDbContainer
|
|
248
|
+
|
|
249
|
+
constructor_kwargs: dict[str, Any] = {
|
|
250
|
+
"image": image,
|
|
251
|
+
"username": username,
|
|
252
|
+
"password": password,
|
|
253
|
+
"port": port,
|
|
254
|
+
**kwargs,
|
|
255
|
+
}
|
|
256
|
+
return _make_generic(
|
|
257
|
+
MongoDbContainer,
|
|
258
|
+
service_slug="mongo",
|
|
259
|
+
reuse_name=reuse_name,
|
|
260
|
+
env=env,
|
|
261
|
+
constructor_kwargs=constructor_kwargs,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def make_rabbitmq(
|
|
266
|
+
image: str = "rabbitmq:3-management",
|
|
267
|
+
*,
|
|
268
|
+
username: str = "guest",
|
|
269
|
+
password: str = "guest",
|
|
270
|
+
port: int = 5672,
|
|
271
|
+
env: dict[str, str] | None = None,
|
|
272
|
+
reuse_name: str | None = None,
|
|
273
|
+
**kwargs: Any,
|
|
274
|
+
):
|
|
275
|
+
"""Context manager wrapping ``RabbitMqContainer``."""
|
|
276
|
+
from testcontainers.rabbitmq import RabbitMqContainer
|
|
277
|
+
|
|
278
|
+
constructor_kwargs: dict[str, Any] = {
|
|
279
|
+
"image": image,
|
|
280
|
+
"username": username,
|
|
281
|
+
"password": password,
|
|
282
|
+
"port": port,
|
|
283
|
+
**kwargs,
|
|
284
|
+
}
|
|
285
|
+
return _make_generic(
|
|
286
|
+
RabbitMqContainer,
|
|
287
|
+
service_slug="rabbitmq",
|
|
288
|
+
reuse_name=reuse_name,
|
|
289
|
+
env=env,
|
|
290
|
+
constructor_kwargs=constructor_kwargs,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def make_container(
|
|
295
|
+
container_cls: type[DockerContainer],
|
|
296
|
+
*args: Any,
|
|
297
|
+
reuse_name: str | None = None,
|
|
298
|
+
env: dict[str, str] | None = None,
|
|
299
|
+
**kwargs: Any,
|
|
300
|
+
):
|
|
301
|
+
"""Generic escape hatch for services we don't ship a maker for.
|
|
302
|
+
|
|
303
|
+
Same plumbing (daemon ping, reuse, atexit, Ryuk-disable-when-reuse).
|
|
304
|
+
"""
|
|
305
|
+
return _make_generic(
|
|
306
|
+
container_cls,
|
|
307
|
+
*args,
|
|
308
|
+
service_slug=_service_slug_for(container_cls),
|
|
309
|
+
reuse_name=reuse_name,
|
|
310
|
+
env=env,
|
|
311
|
+
constructor_kwargs=kwargs,
|
|
312
|
+
)
|