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.
@@ -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