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,37 @@
|
|
|
1
|
+
"""Named pytest fixtures + maker convention on top of testcontainers-python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pytest_testcontainers._internal.conn_info import DbConnInfo
|
|
6
|
+
from pytest_testcontainers.errors import (
|
|
7
|
+
CleanSessionFixtureError,
|
|
8
|
+
ContainerStartError,
|
|
9
|
+
DockerNotRunningError,
|
|
10
|
+
PytestTestcontainersError,
|
|
11
|
+
ReuseConflictError,
|
|
12
|
+
)
|
|
13
|
+
from pytest_testcontainers.makers import (
|
|
14
|
+
make_container,
|
|
15
|
+
make_mongo,
|
|
16
|
+
make_mysql,
|
|
17
|
+
make_postgres,
|
|
18
|
+
make_rabbitmq,
|
|
19
|
+
make_redis,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CleanSessionFixtureError",
|
|
24
|
+
"ContainerStartError",
|
|
25
|
+
"DbConnInfo",
|
|
26
|
+
"DockerNotRunningError",
|
|
27
|
+
"PytestTestcontainersError",
|
|
28
|
+
"ReuseConflictError",
|
|
29
|
+
"make_container",
|
|
30
|
+
"make_mongo",
|
|
31
|
+
"make_mysql",
|
|
32
|
+
"make_postgres",
|
|
33
|
+
"make_rabbitmq",
|
|
34
|
+
"make_redis",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal helpers — not part of the public API."""
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Admin commands behind clean-session fixtures.
|
|
2
|
+
|
|
3
|
+
Each service gets a tiny dispatch table:
|
|
4
|
+
|
|
5
|
+
* ``create_<service>_db`` / ``drop_<service>_db`` (Postgres, MySQL, Mongo)
|
|
6
|
+
* ``flush_redis`` (Redis only — exec-only by design)
|
|
7
|
+
|
|
8
|
+
Both branches (Python client + ``docker exec``) raise the same
|
|
9
|
+
:class:`CleanSessionFixtureError` so user code sees one exception type.
|
|
10
|
+
|
|
11
|
+
All commands invoked inside the container go through docker-py's
|
|
12
|
+
``exec_run`` with an argv list (never a shell string). Generated
|
|
13
|
+
identifiers are produced by us via ``secrets.token_hex`` upstream and
|
|
14
|
+
are additionally quoted defensively at the SQL layer.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import shlex
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from pytest_testcontainers.errors import CleanSessionFixtureError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from testcontainers.core.container import DockerContainer
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("pytest_testcontainers")
|
|
32
|
+
|
|
33
|
+
_warned_lock = threading.Lock()
|
|
34
|
+
_warned_services: set[str] = set()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_truthy(value: str | None) -> bool:
|
|
38
|
+
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_quiet() -> bool:
|
|
42
|
+
return _is_truthy(os.environ.get("PYTEST_TESTCONTAINERS_QUIET"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _maybe_warn_fallback(service: str, install_hint: str) -> None:
|
|
46
|
+
global _warned_services
|
|
47
|
+
if _is_quiet():
|
|
48
|
+
return
|
|
49
|
+
with _warned_lock:
|
|
50
|
+
if service in _warned_services:
|
|
51
|
+
return
|
|
52
|
+
_warned_services.add(service)
|
|
53
|
+
sys.stderr.write(
|
|
54
|
+
f"[pytest-testcontainers] {service} using docker exec fallback\n"
|
|
55
|
+
f"(~100ms per admin call). For ~20x speedup install the client:\n"
|
|
56
|
+
f" pip install {install_hint}\n"
|
|
57
|
+
f"or via the convenience extra:\n"
|
|
58
|
+
f" pip install pytest-testcontainers[clients]\n"
|
|
59
|
+
f"Suppress this notice with PYTEST_TESTCONTAINERS_QUIET=1.\n"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _reset_warnings_for_tests() -> None:
|
|
64
|
+
global _warned_services
|
|
65
|
+
with _warned_lock:
|
|
66
|
+
_warned_services = set()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_in_container(
|
|
70
|
+
container: DockerContainer,
|
|
71
|
+
argv: list[str],
|
|
72
|
+
*,
|
|
73
|
+
env: dict[str, str] | None = None,
|
|
74
|
+
) -> tuple[int, str, str]:
|
|
75
|
+
"""Execute ``argv`` inside the container; return ``(exit_code, stdout, stderr)``."""
|
|
76
|
+
raw_container = getattr(container, "_container", None)
|
|
77
|
+
if raw_container is None:
|
|
78
|
+
raise CleanSessionFixtureError(
|
|
79
|
+
"container has no underlying docker-py reference",
|
|
80
|
+
command=" ".join(shlex.quote(a) for a in argv),
|
|
81
|
+
stderr="container._container is None",
|
|
82
|
+
exit_code=None,
|
|
83
|
+
)
|
|
84
|
+
exec_kwargs: dict[str, Any] = {"demux": True}
|
|
85
|
+
if env:
|
|
86
|
+
exec_kwargs["environment"] = env
|
|
87
|
+
exit_code, output = raw_container.exec_run(argv, **exec_kwargs)
|
|
88
|
+
if isinstance(output, tuple):
|
|
89
|
+
stdout_b, stderr_b = output
|
|
90
|
+
else:
|
|
91
|
+
stdout_b, stderr_b = output, b""
|
|
92
|
+
stdout = (stdout_b or b"").decode(errors="replace")
|
|
93
|
+
stderr = (stderr_b or b"").decode(errors="replace")
|
|
94
|
+
return exit_code, stdout, stderr
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _pg_admin_kwargs(container: DockerContainer) -> dict[str, Any]:
|
|
98
|
+
from pytest_testcontainers._internal.port_resolver import normalize_host
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"host": normalize_host(container.get_container_host_ip()),
|
|
102
|
+
"port": int(container.get_exposed_port(5432)),
|
|
103
|
+
"user": container.username,
|
|
104
|
+
"password": container.password,
|
|
105
|
+
"dbname": "postgres",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pg_create_via_psycopg(container: DockerContainer, name: str) -> None:
|
|
110
|
+
import psycopg
|
|
111
|
+
from psycopg import sql
|
|
112
|
+
|
|
113
|
+
kwargs = _pg_admin_kwargs(container)
|
|
114
|
+
try:
|
|
115
|
+
with psycopg.connect(autocommit=True, **kwargs) as conn, conn.cursor() as cur:
|
|
116
|
+
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
raise CleanSessionFixtureError(
|
|
119
|
+
f"CREATE DATABASE {name!r} failed via psycopg",
|
|
120
|
+
command=f"CREATE DATABASE {name!r}",
|
|
121
|
+
stderr=repr(exc),
|
|
122
|
+
exit_code=None,
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pg_drop_via_psycopg(container: DockerContainer, name: str) -> None:
|
|
127
|
+
import psycopg
|
|
128
|
+
from psycopg import sql
|
|
129
|
+
|
|
130
|
+
kwargs = _pg_admin_kwargs(container)
|
|
131
|
+
try:
|
|
132
|
+
with psycopg.connect(autocommit=True, **kwargs) as conn, conn.cursor() as cur:
|
|
133
|
+
cur.execute(
|
|
134
|
+
sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name))
|
|
135
|
+
)
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
raise CleanSessionFixtureError(
|
|
138
|
+
f"DROP DATABASE {name!r} failed via psycopg",
|
|
139
|
+
command=f"DROP DATABASE {name!r} WITH (FORCE)",
|
|
140
|
+
stderr=repr(exc),
|
|
141
|
+
exit_code=None,
|
|
142
|
+
) from exc
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _pg_run_in_container(container: DockerContainer, sql_text: str) -> None:
|
|
146
|
+
argv = [
|
|
147
|
+
"psql",
|
|
148
|
+
"-U",
|
|
149
|
+
container.username,
|
|
150
|
+
"-d",
|
|
151
|
+
"postgres",
|
|
152
|
+
"-v",
|
|
153
|
+
"ON_ERROR_STOP=1",
|
|
154
|
+
"-c",
|
|
155
|
+
sql_text,
|
|
156
|
+
]
|
|
157
|
+
env = {"PGPASSWORD": container.password}
|
|
158
|
+
exit_code, stdout, stderr = _run_in_container(container, argv, env=env)
|
|
159
|
+
if exit_code != 0:
|
|
160
|
+
raise CleanSessionFixtureError(
|
|
161
|
+
f"psql admin command failed (exit={exit_code})",
|
|
162
|
+
command=sql_text,
|
|
163
|
+
stderr=(stderr or stdout).strip(),
|
|
164
|
+
exit_code=exit_code,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def create_postgres_db(container: DockerContainer, name: str) -> None:
|
|
169
|
+
"""Issue ``CREATE DATABASE <name>`` against the session container."""
|
|
170
|
+
try:
|
|
171
|
+
import psycopg # noqa: F401
|
|
172
|
+
except ImportError:
|
|
173
|
+
_maybe_warn_fallback("tc_psql_db", "psycopg[binary]")
|
|
174
|
+
_pg_run_in_container(container, f'CREATE DATABASE "{name}"')
|
|
175
|
+
return
|
|
176
|
+
_pg_create_via_psycopg(container, name)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def drop_postgres_db(container: DockerContainer, name: str) -> None:
|
|
180
|
+
"""Issue ``DROP DATABASE <name> WITH (FORCE)``."""
|
|
181
|
+
try:
|
|
182
|
+
import psycopg # noqa: F401
|
|
183
|
+
except ImportError:
|
|
184
|
+
_pg_run_in_container(container, f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
|
185
|
+
return
|
|
186
|
+
_pg_drop_via_psycopg(container, name)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _mysql_run_in_container(container: DockerContainer, sql_text: str) -> None:
|
|
190
|
+
argv = [
|
|
191
|
+
"mysql",
|
|
192
|
+
f"-u{container.username}",
|
|
193
|
+
f"-p{container.password}",
|
|
194
|
+
"-e",
|
|
195
|
+
sql_text,
|
|
196
|
+
]
|
|
197
|
+
exit_code, stdout, stderr = _run_in_container(container, argv)
|
|
198
|
+
if exit_code != 0:
|
|
199
|
+
raise CleanSessionFixtureError(
|
|
200
|
+
f"mysql admin command failed (exit={exit_code})",
|
|
201
|
+
command=sql_text,
|
|
202
|
+
stderr=(stderr or stdout).strip(),
|
|
203
|
+
exit_code=exit_code,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _mysql_run_pymysql(container: DockerContainer, sql_text: str) -> None:
|
|
208
|
+
import pymysql
|
|
209
|
+
|
|
210
|
+
from pytest_testcontainers._internal.port_resolver import normalize_host
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
conn = pymysql.connect(
|
|
214
|
+
host=normalize_host(container.get_container_host_ip()),
|
|
215
|
+
port=int(container.get_exposed_port(3306)),
|
|
216
|
+
user=container.username,
|
|
217
|
+
password=container.password,
|
|
218
|
+
autocommit=True,
|
|
219
|
+
)
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
raise CleanSessionFixtureError(
|
|
222
|
+
"mysql admin connect failed",
|
|
223
|
+
command=sql_text,
|
|
224
|
+
stderr=repr(exc),
|
|
225
|
+
exit_code=None,
|
|
226
|
+
) from exc
|
|
227
|
+
try:
|
|
228
|
+
with conn.cursor() as cur:
|
|
229
|
+
cur.execute(sql_text)
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
raise CleanSessionFixtureError(
|
|
232
|
+
"mysql admin command failed via pymysql",
|
|
233
|
+
command=sql_text,
|
|
234
|
+
stderr=repr(exc),
|
|
235
|
+
exit_code=None,
|
|
236
|
+
) from exc
|
|
237
|
+
finally:
|
|
238
|
+
try:
|
|
239
|
+
conn.close()
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
logger.debug("pymysql conn.close() failed: %r", exc)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def create_mysql_db(container: DockerContainer, name: str) -> None:
|
|
245
|
+
try:
|
|
246
|
+
import pymysql # noqa: F401
|
|
247
|
+
except ImportError:
|
|
248
|
+
_maybe_warn_fallback("tc_mysql_db", "pymysql")
|
|
249
|
+
_mysql_run_in_container(container, f"CREATE DATABASE `{name}`")
|
|
250
|
+
return
|
|
251
|
+
_mysql_run_pymysql(container, f"CREATE DATABASE `{name}`")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def drop_mysql_db(container: DockerContainer, name: str) -> None:
|
|
255
|
+
try:
|
|
256
|
+
import pymysql # noqa: F401
|
|
257
|
+
except ImportError:
|
|
258
|
+
_mysql_run_in_container(container, f"DROP DATABASE IF EXISTS `{name}`")
|
|
259
|
+
return
|
|
260
|
+
_mysql_run_pymysql(container, f"DROP DATABASE IF EXISTS `{name}`")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _mongo_run_in_container_drop(container: DockerContainer, name: str) -> None:
|
|
264
|
+
uri = (
|
|
265
|
+
f"mongodb://{container.username}:{container.password}"
|
|
266
|
+
f"@127.0.0.1:27017/admin?authSource=admin"
|
|
267
|
+
)
|
|
268
|
+
js = f"db.getSiblingDB({name!r}).dropDatabase()"
|
|
269
|
+
argv = ["mongosh", "--quiet", uri, "--eval", js]
|
|
270
|
+
exit_code, stdout, stderr = _run_in_container(container, argv)
|
|
271
|
+
if exit_code != 0:
|
|
272
|
+
raise CleanSessionFixtureError(
|
|
273
|
+
f"mongosh dropDatabase failed (exit={exit_code})",
|
|
274
|
+
command=js,
|
|
275
|
+
stderr=(stderr or stdout).strip(),
|
|
276
|
+
exit_code=exit_code,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _mongo_run_pymongo_drop(container: DockerContainer, name: str) -> None:
|
|
281
|
+
from pymongo import MongoClient
|
|
282
|
+
|
|
283
|
+
from pytest_testcontainers._internal.port_resolver import normalize_host
|
|
284
|
+
|
|
285
|
+
host = normalize_host(container.get_container_host_ip())
|
|
286
|
+
port = int(container.get_exposed_port(27017))
|
|
287
|
+
try:
|
|
288
|
+
client = MongoClient(
|
|
289
|
+
host=host,
|
|
290
|
+
port=port,
|
|
291
|
+
username=container.username,
|
|
292
|
+
password=container.password,
|
|
293
|
+
serverSelectionTimeoutMS=5000,
|
|
294
|
+
)
|
|
295
|
+
try:
|
|
296
|
+
client.drop_database(name)
|
|
297
|
+
finally:
|
|
298
|
+
client.close()
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
raise CleanSessionFixtureError(
|
|
301
|
+
"mongo dropDatabase failed via pymongo",
|
|
302
|
+
command=f"dropDatabase({name!r})",
|
|
303
|
+
stderr=repr(exc),
|
|
304
|
+
exit_code=None,
|
|
305
|
+
) from exc
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def drop_mongo_db(container: DockerContainer, name: str) -> None:
|
|
309
|
+
"""Drop a Mongo database. First write materializes it, so no create step."""
|
|
310
|
+
try:
|
|
311
|
+
import pymongo # noqa: F401
|
|
312
|
+
except ImportError:
|
|
313
|
+
_maybe_warn_fallback("tc_mongo_db", "pymongo")
|
|
314
|
+
_mongo_run_in_container_drop(container, name)
|
|
315
|
+
return
|
|
316
|
+
_mongo_run_pymongo_drop(container, name)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def flush_redis(container: DockerContainer) -> None:
|
|
320
|
+
"""Run ``redis-cli FLUSHALL`` inside the container (exec-only by design)."""
|
|
321
|
+
argv = ["redis-cli", "FLUSHALL"]
|
|
322
|
+
exit_code, stdout, stderr = _run_in_container(container, argv)
|
|
323
|
+
if exit_code != 0:
|
|
324
|
+
raise CleanSessionFixtureError(
|
|
325
|
+
f"redis-cli FLUSHALL failed (exit={exit_code})",
|
|
326
|
+
command="redis-cli FLUSHALL",
|
|
327
|
+
stderr=(stderr or stdout).strip(),
|
|
328
|
+
exit_code=exit_code,
|
|
329
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""``DbConnInfo`` — connection-info dataclass yielded by clean-session DB fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class DbConnInfo:
|
|
10
|
+
"""Frozen bundle of connection coordinates for a clean-session DB fixture.
|
|
11
|
+
|
|
12
|
+
Yielded by ``tc_psql_db`` / ``tc_mysql_db`` / ``tc_mongo_db``. The
|
|
13
|
+
fixture is about isolation, not about handing back a live client —
|
|
14
|
+
user code constructs its preferred client/ORM/URL from these fields.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
host: str
|
|
18
|
+
port: int
|
|
19
|
+
username: str
|
|
20
|
+
password: str
|
|
21
|
+
database: str
|
|
22
|
+
|
|
23
|
+
def url(self, scheme: str = "postgresql") -> str:
|
|
24
|
+
"""Render a DB URL of the form ``<scheme>://user:pass@host:port/db``.
|
|
25
|
+
|
|
26
|
+
``scheme`` defaults to ``"postgresql"``. Pass ``"mysql"``,
|
|
27
|
+
``"mongodb"``, or any other scheme as needed — no validation.
|
|
28
|
+
"""
|
|
29
|
+
return f"{scheme}://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Docker daemon health check — single source of truth for daemon ping.
|
|
2
|
+
|
|
3
|
+
The cache stores **only successful pings**. A successful ping flips a
|
|
4
|
+
process-wide flag; subsequent maker calls in the same process skip the
|
|
5
|
+
probe. A failed ping does NOT poison the cache, so a transient daemon
|
|
6
|
+
restart inside a long-running pytest session does not permanently
|
|
7
|
+
disable the plugin.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
from pytest_testcontainers.errors import DockerNotRunningError
|
|
16
|
+
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_ping_ok: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_truthy(value: str | None) -> bool:
|
|
22
|
+
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reset_cache() -> None:
|
|
26
|
+
"""Reset the success cache. Used in tests."""
|
|
27
|
+
global _ping_ok
|
|
28
|
+
with _lock:
|
|
29
|
+
_ping_ok = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_docker_daemon() -> None:
|
|
33
|
+
"""Ping the Docker daemon once per process (on success).
|
|
34
|
+
|
|
35
|
+
Raises :class:`DockerNotRunningError` (chained from the underlying
|
|
36
|
+
docker-py exception) if the daemon cannot be reached.
|
|
37
|
+
"""
|
|
38
|
+
global _ping_ok
|
|
39
|
+
if _ping_ok:
|
|
40
|
+
return
|
|
41
|
+
if _is_truthy(os.environ.get("PYTEST_TESTCONTAINERS_NO_DAEMON_CHECK")):
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
import docker # local import: never trigger at module-import time
|
|
46
|
+
except ImportError as exc: # pragma: no cover — docker is a hard dep
|
|
47
|
+
raise DockerNotRunningError(
|
|
48
|
+
"docker-py is not installed; cannot ping Docker daemon"
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
client = docker.from_env()
|
|
53
|
+
client.ping()
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
# Surface the underlying daemon failure with a concrete cause.
|
|
56
|
+
raise DockerNotRunningError(f"docker.from_env().ping() failed: {exc!r}") from exc
|
|
57
|
+
|
|
58
|
+
with _lock:
|
|
59
|
+
_ping_ok = True
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Host/port resolution that normalizes Docker's quirks.
|
|
2
|
+
|
|
3
|
+
Docker's ``NetworkSettings.Ports`` returns ``HostIp`` strings that vary
|
|
4
|
+
across platforms: ``""`` and ``"0.0.0.0"`` on Linux, ``"::"`` on macOS
|
|
5
|
+
with Docker Desktop. We normalize all of those to ``localhost`` so test
|
|
6
|
+
code does not have to know about the platform difference.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
_LOOPBACK_ALIASES = {"", "0.0.0.0", "::", "::0"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_host(host: str | None) -> str:
|
|
15
|
+
"""Map Docker's ``HostIp`` to a usable hostname for connecting back."""
|
|
16
|
+
if host is None:
|
|
17
|
+
return "localhost"
|
|
18
|
+
if host in _LOOPBACK_ALIASES:
|
|
19
|
+
return "localhost"
|
|
20
|
+
return host
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Reuse lookup, atexit registration, Ryuk-disable choreography.
|
|
2
|
+
|
|
3
|
+
Lives between the maker layer (which orchestrates lifecycle) and the
|
|
4
|
+
underlying ``testcontainers-python`` / ``docker-py`` libraries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
import traceback
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from pytest_testcontainers.errors import (
|
|
16
|
+
ContainerStartError,
|
|
17
|
+
ReuseConflictError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from testcontainers.core.container import DockerContainer
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("pytest_testcontainers")
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------------------------------
|
|
26
|
+
# Ryuk disable — idempotent, lazy
|
|
27
|
+
# --------------------------------------------------------------------------
|
|
28
|
+
_ryuk_lock = threading.Lock()
|
|
29
|
+
_ryuk_disabled: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def disable_ryuk_once() -> None:
|
|
33
|
+
"""Flip ``testcontainers_config.ryuk_disabled = True`` exactly once.
|
|
34
|
+
|
|
35
|
+
Per §8.5: testcontainers-python does not spawn the Ryuk reaper at
|
|
36
|
+
import time; the flag must just be set before the first container's
|
|
37
|
+
``.start()``. We do it lazily inside the maker so module import has
|
|
38
|
+
no side effects.
|
|
39
|
+
"""
|
|
40
|
+
global _ryuk_disabled
|
|
41
|
+
with _ryuk_lock:
|
|
42
|
+
if _ryuk_disabled:
|
|
43
|
+
return
|
|
44
|
+
try:
|
|
45
|
+
from testcontainers.core.config import testcontainers_config
|
|
46
|
+
except ImportError: # pragma: no cover — testcontainers is a hard dep
|
|
47
|
+
return
|
|
48
|
+
testcontainers_config.ryuk_disabled = True
|
|
49
|
+
_ryuk_disabled = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------
|
|
53
|
+
# atexit safety net — only fires when pytest_unconfigure is skipped
|
|
54
|
+
# --------------------------------------------------------------------------
|
|
55
|
+
_atexit_lock = threading.Lock()
|
|
56
|
+
_atexit_callbacks: dict[int, Any] = {} # id(container) -> bound callback
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _make_atexit_stop(container: DockerContainer) -> Any:
|
|
60
|
+
def _cb() -> None:
|
|
61
|
+
try:
|
|
62
|
+
container.stop()
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
# Common case: normal teardown already stopped+removed the
|
|
65
|
+
# container, so a Docker NotFound is expected and silent.
|
|
66
|
+
# Anything else gets printed (no re-raise — interpreter
|
|
67
|
+
# shutdown would discard the exception anyway). Per CLAUDE.md,
|
|
68
|
+
# this is the one suppress-exception pattern allowed; the
|
|
69
|
+
# narrowed branch + log make the rationale auditable.
|
|
70
|
+
try:
|
|
71
|
+
from docker.errors import NotFound
|
|
72
|
+
except ImportError: # pragma: no cover
|
|
73
|
+
NotFound = () # type: ignore[assignment]
|
|
74
|
+
if not isinstance(exc, NotFound):
|
|
75
|
+
traceback.print_exc()
|
|
76
|
+
|
|
77
|
+
return _cb
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def register_atexit_stop(container: DockerContainer) -> None:
|
|
81
|
+
"""Register an atexit callback that stops ``container`` exactly once."""
|
|
82
|
+
key = id(container)
|
|
83
|
+
with _atexit_lock:
|
|
84
|
+
if key in _atexit_callbacks:
|
|
85
|
+
return
|
|
86
|
+
cb = _make_atexit_stop(container)
|
|
87
|
+
_atexit_callbacks[key] = cb
|
|
88
|
+
atexit.register(cb)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def deregister_atexit_stop(container: DockerContainer) -> None:
|
|
92
|
+
"""Remove the atexit callback for ``container`` after normal cleanup."""
|
|
93
|
+
key = id(container)
|
|
94
|
+
with _atexit_lock:
|
|
95
|
+
cb = _atexit_callbacks.pop(key, None)
|
|
96
|
+
if cb is not None:
|
|
97
|
+
atexit.unregister(cb)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------
|
|
101
|
+
# Reuse-by-name lookup
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
def find_existing_container(name: str) -> Any | None:
|
|
104
|
+
"""Return the docker-py container object for ``name``, or None.
|
|
105
|
+
|
|
106
|
+
"Found" includes any state — the caller decides what to do based on
|
|
107
|
+
``container.status`` (``"running"``, ``"exited"``, ``"created"``,
|
|
108
|
+
``"paused"``, …).
|
|
109
|
+
"""
|
|
110
|
+
import docker
|
|
111
|
+
from docker.errors import NotFound
|
|
112
|
+
|
|
113
|
+
client = docker.from_env()
|
|
114
|
+
try:
|
|
115
|
+
container = client.containers.get(name)
|
|
116
|
+
except NotFound:
|
|
117
|
+
return None
|
|
118
|
+
return container
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def restart_stopped_or_raise(docker_container: Any, name: str) -> None:
|
|
122
|
+
"""Bring a stopped/created/paused container back to running.
|
|
123
|
+
|
|
124
|
+
Raises :class:`ReuseConflictError` if Docker refuses to start it
|
|
125
|
+
(typically because the previously-mapped host port is now taken).
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
docker_container.start()
|
|
129
|
+
docker_container.reload()
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
raise ReuseConflictError(f"cannot start existing container {name!r}: {exc!r}") from exc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def bind_to_running(
|
|
135
|
+
container_cls: type[DockerContainer],
|
|
136
|
+
docker_container: Any,
|
|
137
|
+
*constructor_args: Any,
|
|
138
|
+
**constructor_kwargs: Any,
|
|
139
|
+
) -> DockerContainer:
|
|
140
|
+
"""Build a ``testcontainers-python`` instance bound to a running container.
|
|
141
|
+
|
|
142
|
+
The instance is constructed but ``.start()`` is *not* called — instead
|
|
143
|
+
we set the private attributes that ``.start()`` would normally set
|
|
144
|
+
so that ``.get_container_host_ip()`` / ``.get_exposed_port()`` work
|
|
145
|
+
against the existing container.
|
|
146
|
+
"""
|
|
147
|
+
instance = container_cls(*constructor_args, **constructor_kwargs)
|
|
148
|
+
# testcontainers-python's DockerContainer keeps the live wrapper as
|
|
149
|
+
# `_container`. After `.start()`, this attribute is the docker-py
|
|
150
|
+
# Container object; before, it's None.
|
|
151
|
+
instance._container = docker_container
|
|
152
|
+
return instance
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
# Convenience wrapper: wrap a .start() call so failures convert cleanly
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
def start_or_raise(container: DockerContainer, image_for_msg: str) -> None:
|
|
159
|
+
"""Call ``container.start()`` and convert failures to ``ContainerStartError``."""
|
|
160
|
+
try:
|
|
161
|
+
container.start()
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
log_tail = ""
|
|
164
|
+
try:
|
|
165
|
+
raw = container.get_logs() if hasattr(container, "get_logs") else None
|
|
166
|
+
if raw:
|
|
167
|
+
# testcontainers' get_logs returns (stdout, stderr) bytes
|
|
168
|
+
stdout = raw[0] if isinstance(raw, tuple) else raw
|
|
169
|
+
if isinstance(stdout, bytes):
|
|
170
|
+
log_tail = stdout.decode(errors="replace")
|
|
171
|
+
else:
|
|
172
|
+
log_tail = str(stdout)
|
|
173
|
+
# last 50 lines
|
|
174
|
+
log_tail = "\n".join(log_tail.splitlines()[-50:])
|
|
175
|
+
except Exception as log_exc:
|
|
176
|
+
logger.debug("could not retrieve container logs: %r", log_exc)
|
|
177
|
+
|
|
178
|
+
message = f"failed to start container (image={image_for_msg!r}): {exc!r}"
|
|
179
|
+
if log_tail:
|
|
180
|
+
message = f"{message}\n--- last 50 log lines ---\n{log_tail}"
|
|
181
|
+
raise ContainerStartError(message) from exc
|