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,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
+ )