pytest-testcontainers-django 0.2.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_django/__init__.py +45 -0
- pytest_testcontainers_django/_types.py +63 -0
- pytest_testcontainers_django/config.py +232 -0
- pytest_testcontainers_django/containers.py +252 -0
- pytest_testcontainers_django/errors.py +31 -0
- pytest_testcontainers_django/injection.py +98 -0
- pytest_testcontainers_django/plugin.py +273 -0
- pytest_testcontainers_django-0.2.0.dist-info/METADATA +371 -0
- pytest_testcontainers_django-0.2.0.dist-info/RECORD +12 -0
- pytest_testcontainers_django-0.2.0.dist-info/WHEEL +4 -0
- pytest_testcontainers_django-0.2.0.dist-info/entry_points.txt +2 -0
- pytest_testcontainers_django-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Bridge between ``pytest-testcontainers`` and ``pytest-django``.
|
|
2
|
+
|
|
3
|
+
The pytest plugin auto-loads via the ``pytest11`` entry point declared
|
|
4
|
+
in ``pyproject.toml`` — no ``-p`` flag or ``conftest.py`` import is
|
|
5
|
+
required. Most users only need to interact with the public surface
|
|
6
|
+
re-exported here.
|
|
7
|
+
|
|
8
|
+
See ``SPEC.md`` for the full design rationale (especially §6 on the
|
|
9
|
+
hook-ordering timing dance).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pytest_testcontainers_django._types import (
|
|
15
|
+
DjangoContainerConfig,
|
|
16
|
+
PostgresService,
|
|
17
|
+
RedisService,
|
|
18
|
+
)
|
|
19
|
+
from pytest_testcontainers_django.config import register
|
|
20
|
+
from pytest_testcontainers_django.errors import (
|
|
21
|
+
BaselineMissingError,
|
|
22
|
+
ConfigError,
|
|
23
|
+
PytestTestcontainersDjangoError,
|
|
24
|
+
ReuseStaleContainerError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BaselineMissingError",
|
|
29
|
+
"ConfigError",
|
|
30
|
+
"DjangoContainerConfig",
|
|
31
|
+
"PostgresService",
|
|
32
|
+
"PytestTestcontainersDjangoError",
|
|
33
|
+
"RedisService",
|
|
34
|
+
"ReuseStaleContainerError",
|
|
35
|
+
"register",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
from importlib.metadata import PackageNotFoundError
|
|
39
|
+
from importlib.metadata import version as _pkg_version
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
__version__ = _pkg_version("pytest-testcontainers-django")
|
|
43
|
+
except PackageNotFoundError: # pragma: no cover — only when running from a non-installed checkout
|
|
44
|
+
__version__ = "0.0.0+unknown"
|
|
45
|
+
del PackageNotFoundError, _pkg_version
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Public dataclasses used for programmatic configuration via :func:`register`.
|
|
2
|
+
|
|
3
|
+
See SPEC §4.2 / §5. Default env-var names follow the ``DJANGO_DB_*`` /
|
|
4
|
+
``DJANGO_REDIS_*`` prefix idiom (SPEC §5.1.3).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
DEFAULT_DB_ENV_NAMES: dict[str, str] = {
|
|
13
|
+
"host": "DJANGO_DB_HOST",
|
|
14
|
+
"port": "DJANGO_DB_PORT",
|
|
15
|
+
"name": "DJANGO_DB_NAME",
|
|
16
|
+
"user": "DJANGO_DB_USER",
|
|
17
|
+
"password": "DJANGO_DB_PASSWORD",
|
|
18
|
+
"test_template": "DJANGO_DB_TEST_TEMPLATE",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
DEFAULT_REDIS_ENV_NAMES: dict[str, str] = {
|
|
22
|
+
"host": "DJANGO_REDIS_HOST",
|
|
23
|
+
"port": "DJANGO_REDIS_PORT",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PostgresService:
|
|
29
|
+
image: str = "postgres:16"
|
|
30
|
+
user: str = "postgres"
|
|
31
|
+
password: str = "postgres"
|
|
32
|
+
database: str = "postgres"
|
|
33
|
+
internal_port: int = 5432
|
|
34
|
+
template: str | None = None
|
|
35
|
+
init_scripts: list[Path] = field(default_factory=list)
|
|
36
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
env_names: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_DB_ENV_NAMES))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RedisService:
|
|
42
|
+
image: str = "redis:7-alpine"
|
|
43
|
+
internal_port: int = 6379
|
|
44
|
+
env_names: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_REDIS_ENV_NAMES))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class DjangoContainerConfig:
|
|
49
|
+
postgres: PostgresService = field(default_factory=PostgresService)
|
|
50
|
+
redis: RedisService | None = None
|
|
51
|
+
skip_dotenv_env: str = "DJANGO_SKIP_DOTENV"
|
|
52
|
+
disable_env: str = "PYTEST_TESTCONTAINERS_DISABLE"
|
|
53
|
+
reuse_env: str = "PYTEST_TESTCONTAINERS_REUSE"
|
|
54
|
+
use_django_pg_baseline: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"DEFAULT_DB_ENV_NAMES",
|
|
59
|
+
"DEFAULT_REDIS_ENV_NAMES",
|
|
60
|
+
"DjangoContainerConfig",
|
|
61
|
+
"PostgresService",
|
|
62
|
+
"RedisService",
|
|
63
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Pyproject reader, ``register()`` API, validation.
|
|
2
|
+
|
|
3
|
+
Resolution order (SPEC §5.1):
|
|
4
|
+
|
|
5
|
+
1. ``register()`` value if ``conftest.py`` called it.
|
|
6
|
+
2. ``[tool.pytest-testcontainers-django]`` table in nearest pyproject.toml.
|
|
7
|
+
3. Built-in defaults from :mod:`pytest_testcontainers_django._types`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import replace
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
if sys.version_info >= (3, 11):
|
|
19
|
+
import tomllib
|
|
20
|
+
else: # pragma: no cover — exercised on 3.10
|
|
21
|
+
import tomli as tomllib
|
|
22
|
+
|
|
23
|
+
from pytest_testcontainers_django._types import (
|
|
24
|
+
DEFAULT_DB_ENV_NAMES,
|
|
25
|
+
DEFAULT_REDIS_ENV_NAMES,
|
|
26
|
+
DjangoContainerConfig,
|
|
27
|
+
PostgresService,
|
|
28
|
+
RedisService,
|
|
29
|
+
)
|
|
30
|
+
from pytest_testcontainers_django.errors import ConfigError
|
|
31
|
+
|
|
32
|
+
_REGISTERED: DjangoContainerConfig | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register(config: DjangoContainerConfig) -> None:
|
|
36
|
+
"""Register a programmatic configuration from ``conftest.py``.
|
|
37
|
+
|
|
38
|
+
Overrides any ``[tool.pytest-testcontainers-django]`` pyproject table.
|
|
39
|
+
Last call wins (mostly relevant in test scenarios).
|
|
40
|
+
"""
|
|
41
|
+
global _REGISTERED
|
|
42
|
+
if not isinstance(config, DjangoContainerConfig):
|
|
43
|
+
raise TypeError(
|
|
44
|
+
f"register() requires a DjangoContainerConfig instance, got {type(config).__name__}"
|
|
45
|
+
)
|
|
46
|
+
_REGISTERED = config
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _reset_registered() -> None:
|
|
50
|
+
"""Test-only helper. Not part of the public API."""
|
|
51
|
+
global _REGISTERED
|
|
52
|
+
_REGISTERED = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _find_pyproject(start: Path) -> Path | None:
|
|
56
|
+
"""Walk up from *start* looking for the nearest ``pyproject.toml``."""
|
|
57
|
+
current = start.resolve()
|
|
58
|
+
for candidate in [current, *current.parents]:
|
|
59
|
+
pyproject = candidate / "pyproject.toml"
|
|
60
|
+
if pyproject.is_file():
|
|
61
|
+
return pyproject
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_pyproject_table(pyproject: Path) -> dict[str, Any]:
|
|
66
|
+
with pyproject.open("rb") as fh:
|
|
67
|
+
data = tomllib.load(fh)
|
|
68
|
+
tool = data.get("tool", {})
|
|
69
|
+
return tool.get("pytest-testcontainers-django", {}) or {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_init_scripts(raw: list[str | Path] | None, project_root: Path) -> list[Path]:
|
|
73
|
+
if not raw:
|
|
74
|
+
return []
|
|
75
|
+
out: list[Path] = []
|
|
76
|
+
for entry in raw:
|
|
77
|
+
path = Path(entry)
|
|
78
|
+
if not path.is_absolute():
|
|
79
|
+
path = project_root / path
|
|
80
|
+
out.append(path)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _config_from_table(table: dict[str, Any], project_root: Path) -> DjangoContainerConfig:
|
|
85
|
+
"""Translate a parsed ``[tool.pytest-testcontainers-django]`` table.
|
|
86
|
+
|
|
87
|
+
Unknown keys are ignored — TOML errors should be loud, but typos in
|
|
88
|
+
nested optional keys shouldn't break the plugin's bootstrap. (We do
|
|
89
|
+
raise on structurally impossible values, e.g. wrong types.)
|
|
90
|
+
"""
|
|
91
|
+
db_env_names = dict(DEFAULT_DB_ENV_NAMES)
|
|
92
|
+
db_env_names_overrides = {
|
|
93
|
+
"host": table.get("db_host_env"),
|
|
94
|
+
"port": table.get("db_port_env"),
|
|
95
|
+
"name": table.get("db_name_env"),
|
|
96
|
+
"user": table.get("db_user_env"),
|
|
97
|
+
"password": table.get("db_password_env"),
|
|
98
|
+
"test_template": table.get("db_test_template_env"),
|
|
99
|
+
}
|
|
100
|
+
for key, value in db_env_names_overrides.items():
|
|
101
|
+
if value is not None:
|
|
102
|
+
db_env_names[key] = str(value)
|
|
103
|
+
|
|
104
|
+
postgres = PostgresService(
|
|
105
|
+
image=str(table.get("postgres_image", PostgresService.image)),
|
|
106
|
+
user=str(table.get("postgres_user", PostgresService.user)),
|
|
107
|
+
password=str(table.get("postgres_password", PostgresService.password)),
|
|
108
|
+
database=str(table.get("postgres_database", PostgresService.database)),
|
|
109
|
+
internal_port=int(table.get("postgres_internal_port", PostgresService.internal_port)),
|
|
110
|
+
template=(
|
|
111
|
+
str(table["postgres_template"]) if table.get("postgres_template") is not None else None
|
|
112
|
+
),
|
|
113
|
+
init_scripts=_resolve_init_scripts(table.get("postgres_init_scripts"), project_root),
|
|
114
|
+
env=dict(table.get("postgres_env", {}) or {}),
|
|
115
|
+
env_names=db_env_names,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
redis: RedisService | None = None
|
|
119
|
+
if table.get("redis_enabled", False):
|
|
120
|
+
redis_env_names = dict(DEFAULT_REDIS_ENV_NAMES)
|
|
121
|
+
if (override := table.get("redis_host_env")) is not None:
|
|
122
|
+
redis_env_names["host"] = str(override)
|
|
123
|
+
if (override := table.get("redis_port_env")) is not None:
|
|
124
|
+
redis_env_names["port"] = str(override)
|
|
125
|
+
redis = RedisService(
|
|
126
|
+
image=str(table.get("redis_image", RedisService.image)),
|
|
127
|
+
internal_port=int(table.get("redis_internal_port", RedisService.internal_port)),
|
|
128
|
+
env_names=redis_env_names,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return DjangoContainerConfig(
|
|
132
|
+
postgres=postgres,
|
|
133
|
+
redis=redis,
|
|
134
|
+
skip_dotenv_env=str(table.get("skip_dotenv_env", DjangoContainerConfig.skip_dotenv_env)),
|
|
135
|
+
disable_env=str(table.get("disable_env", DjangoContainerConfig.disable_env)),
|
|
136
|
+
reuse_env=str(table.get("reuse_env", DjangoContainerConfig.reuse_env)),
|
|
137
|
+
use_django_pg_baseline=bool(table.get("use_django_pg_baseline", False)),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_config(rootdir: Path) -> DjangoContainerConfig:
|
|
142
|
+
"""Load configuration. ``register()`` overrides pyproject; defaults fill gaps."""
|
|
143
|
+
if _REGISTERED is not None:
|
|
144
|
+
# register() expresses an override of *defaults*, not of pyproject;
|
|
145
|
+
# but per SPEC §4.2 the documented behavior is "register() wins" —
|
|
146
|
+
# so the registered config replaces the table wholesale.
|
|
147
|
+
return _REGISTERED
|
|
148
|
+
|
|
149
|
+
pyproject = _find_pyproject(rootdir)
|
|
150
|
+
if pyproject is None:
|
|
151
|
+
return DjangoContainerConfig()
|
|
152
|
+
table = _load_pyproject_table(pyproject)
|
|
153
|
+
return _config_from_table(table, project_root=pyproject.parent)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def apply_template_default(config: DjangoContainerConfig) -> DjangoContainerConfig:
|
|
157
|
+
"""SPEC §10.6: when init_scripts non-empty and template unset, default to ``database``.
|
|
158
|
+
|
|
159
|
+
Returns a (possibly) new config; never mutates the input.
|
|
160
|
+
"""
|
|
161
|
+
pg = config.postgres
|
|
162
|
+
if pg.init_scripts and pg.template is None:
|
|
163
|
+
new_pg = replace(pg, template=pg.database)
|
|
164
|
+
return replace(config, postgres=new_pg)
|
|
165
|
+
return config
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def validate(config: DjangoContainerConfig) -> None:
|
|
169
|
+
"""SPEC §5.6 validation.
|
|
170
|
+
|
|
171
|
+
Raises :class:`ConfigError` when the configuration is structurally bad.
|
|
172
|
+
"""
|
|
173
|
+
pg = config.postgres
|
|
174
|
+
required_db_keys = {"host", "port", "name", "user", "password"}
|
|
175
|
+
missing = [k for k in required_db_keys if not pg.env_names.get(k)]
|
|
176
|
+
if missing:
|
|
177
|
+
raise ConfigError(f"PostgresService.env_names is missing required keys: {sorted(missing)}")
|
|
178
|
+
|
|
179
|
+
for path in pg.init_scripts:
|
|
180
|
+
if not path.is_file():
|
|
181
|
+
raise ConfigError(f"postgres_init_scripts entry does not exist: {path}")
|
|
182
|
+
|
|
183
|
+
if pg.template is not None and not pg.init_scripts:
|
|
184
|
+
raise ConfigError(
|
|
185
|
+
"postgres_template is set but postgres_init_scripts is empty — "
|
|
186
|
+
"cloning an empty template DB just slows test creation. "
|
|
187
|
+
"Either add init scripts or remove the template setting."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if config.redis is not None:
|
|
191
|
+
redis_env = config.redis.env_names
|
|
192
|
+
for key in ("host", "port"):
|
|
193
|
+
if not redis_env.get(key):
|
|
194
|
+
raise ConfigError(f"RedisService.env_names is missing required key: {key!r}")
|
|
195
|
+
|
|
196
|
+
if pg.internal_port <= 0 or pg.internal_port > 65535:
|
|
197
|
+
raise ConfigError(f"postgres_internal_port out of range: {pg.internal_port}")
|
|
198
|
+
if config.redis is not None and (
|
|
199
|
+
config.redis.internal_port <= 0 or config.redis.internal_port > 65535
|
|
200
|
+
):
|
|
201
|
+
raise ConfigError(f"redis_internal_port out of range: {config.redis.internal_port}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def is_disabled(config: DjangoContainerConfig, args: list[str]) -> bool:
|
|
205
|
+
"""Active by default. SPEC §6.5 step 5.
|
|
206
|
+
|
|
207
|
+
Disabled by ``--no-testcontainers`` CLI flag or by setting
|
|
208
|
+
``config.disable_env`` to a truthy-falsy value (``1``, ``true``, ``yes``).
|
|
209
|
+
"""
|
|
210
|
+
if "--no-testcontainers" in args:
|
|
211
|
+
return True
|
|
212
|
+
if not config.disable_env:
|
|
213
|
+
return False
|
|
214
|
+
raw = os.environ.get(config.disable_env, "").strip().lower()
|
|
215
|
+
return raw in {"1", "true", "yes", "on"}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def is_reuse_enabled(config: DjangoContainerConfig) -> bool:
|
|
219
|
+
if not config.reuse_env:
|
|
220
|
+
return False
|
|
221
|
+
raw = os.environ.get(config.reuse_env, "").strip().lower()
|
|
222
|
+
return raw in {"1", "true", "yes", "on"}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
__all__ = [
|
|
226
|
+
"apply_template_default",
|
|
227
|
+
"is_disabled",
|
|
228
|
+
"is_reuse_enabled",
|
|
229
|
+
"load_config",
|
|
230
|
+
"register",
|
|
231
|
+
"validate",
|
|
232
|
+
]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Container start/stop bridge.
|
|
2
|
+
|
|
3
|
+
This is the only module that talks to ``testcontainers-python`` directly.
|
|
4
|
+
The Django plugin code calls :func:`start_postgres` / :func:`start_redis`
|
|
5
|
+
and gets back a small handle exposing ``host``, ``port``, ``stop()``.
|
|
6
|
+
|
|
7
|
+
Why not simply call ``pytest_testcontainers.make_postgres``? Two
|
|
8
|
+
practical reasons specific to the Django timing dance:
|
|
9
|
+
|
|
10
|
+
* We need ``with_volume_mapping`` calls **before** ``.start()`` to mount
|
|
11
|
+
init scripts into ``/docker-entrypoint-initdb.d/`` (SPEC §10). #1's
|
|
12
|
+
``make_postgres`` doesn't expose pre-start mutation.
|
|
13
|
+
* We want to detect "we attached to a pre-existing container" so we can
|
|
14
|
+
emit the SPEC §10.7 reuse warning.
|
|
15
|
+
|
|
16
|
+
We *do* lean on #1 for: daemon ping (:class:`pytest_testcontainers.DockerNotRunningError`),
|
|
17
|
+
atexit registration helpers, Ryuk-disable choreography, and
|
|
18
|
+
named-container reuse lookup. Those are imported from #1's surface so
|
|
19
|
+
breakage is contained at one spot if #1 evolves.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from pytest_testcontainers import DockerNotRunningError
|
|
30
|
+
from pytest_testcontainers.containers import (
|
|
31
|
+
bind_to_running,
|
|
32
|
+
deregister_atexit_stop,
|
|
33
|
+
disable_ryuk_once,
|
|
34
|
+
find_existing_container,
|
|
35
|
+
register_atexit_stop,
|
|
36
|
+
restart_stopped_or_raise,
|
|
37
|
+
start_or_raise,
|
|
38
|
+
)
|
|
39
|
+
from pytest_testcontainers.reuse import reuse_name_for
|
|
40
|
+
|
|
41
|
+
from pytest_testcontainers_django.errors import ReuseStaleContainerError
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("pytest_testcontainers_django")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ContainerHandle:
|
|
48
|
+
"""Minimal handle returned by :func:`start_postgres` / :func:`start_redis`."""
|
|
49
|
+
|
|
50
|
+
host: str
|
|
51
|
+
port: int
|
|
52
|
+
name: str | None
|
|
53
|
+
is_bound_to_existing: bool
|
|
54
|
+
_instance: Any # testcontainers DockerContainer; we only call .stop() on it
|
|
55
|
+
_owns_instance: bool # False when bound to a pre-existing reuse container
|
|
56
|
+
|
|
57
|
+
def stop(self) -> None:
|
|
58
|
+
if not self._owns_instance:
|
|
59
|
+
return
|
|
60
|
+
deregister_atexit_stop(self._instance)
|
|
61
|
+
try:
|
|
62
|
+
self._instance.stop()
|
|
63
|
+
except Exception:
|
|
64
|
+
logger.exception("cleanup: container.stop() failed")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_daemon() -> None:
|
|
68
|
+
"""Wrap #1's daemon ping so callers get a stable exception class."""
|
|
69
|
+
import docker
|
|
70
|
+
import docker.errors
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
docker.from_env().ping()
|
|
74
|
+
except docker.errors.DockerException as exc:
|
|
75
|
+
raise DockerNotRunningError(str(exc)) from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _start_or_attach(
|
|
79
|
+
container: Any,
|
|
80
|
+
*,
|
|
81
|
+
reuse_name: str | None,
|
|
82
|
+
) -> tuple[Any, bool]:
|
|
83
|
+
"""Start *container*, or attach to a pre-existing one when reuse is on.
|
|
84
|
+
|
|
85
|
+
Returns ``(instance, is_bound_to_existing)``. When bound to existing,
|
|
86
|
+
the caller MUST NOT register atexit stop and MUST NOT call ``stop()``.
|
|
87
|
+
"""
|
|
88
|
+
if reuse_name is None:
|
|
89
|
+
return container, False
|
|
90
|
+
|
|
91
|
+
disable_ryuk_once()
|
|
92
|
+
existing = find_existing_container(reuse_name)
|
|
93
|
+
if existing is None:
|
|
94
|
+
container.with_name(reuse_name)
|
|
95
|
+
return container, False
|
|
96
|
+
|
|
97
|
+
container_cls = type(container)
|
|
98
|
+
status = getattr(existing, "status", "")
|
|
99
|
+
if status == "running":
|
|
100
|
+
return bind_to_running(container_cls, existing), True
|
|
101
|
+
if status in {"exited", "created", "paused"}:
|
|
102
|
+
restart_stopped_or_raise(existing, reuse_name)
|
|
103
|
+
return bind_to_running(container_cls, existing), True
|
|
104
|
+
# "dead" / "removing" / unknown: a fresh start would fail with a 409
|
|
105
|
+
# name conflict against the stale container, so surface an actionable
|
|
106
|
+
# error. The user has to remove the stale container manually because
|
|
107
|
+
# we won't take destructive action on shared docker state.
|
|
108
|
+
raise ReuseStaleContainerError(
|
|
109
|
+
f"reuse-mode container {reuse_name!r} exists but is in state {status!r} — "
|
|
110
|
+
f"it cannot be restarted and starting a fresh container would conflict on "
|
|
111
|
+
f"the name. Remove it manually:\n"
|
|
112
|
+
f" docker rm -f {reuse_name}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def start_postgres(
|
|
117
|
+
*,
|
|
118
|
+
image: str,
|
|
119
|
+
user: str,
|
|
120
|
+
password: str,
|
|
121
|
+
database: str,
|
|
122
|
+
internal_port: int,
|
|
123
|
+
env: dict[str, str],
|
|
124
|
+
init_scripts: list[Path],
|
|
125
|
+
reuse_name: str | None,
|
|
126
|
+
) -> ContainerHandle:
|
|
127
|
+
"""Construct + start (or attach to) a PostgresContainer."""
|
|
128
|
+
from testcontainers.postgres import PostgresContainer
|
|
129
|
+
|
|
130
|
+
_check_daemon()
|
|
131
|
+
|
|
132
|
+
container = PostgresContainer(
|
|
133
|
+
image=image,
|
|
134
|
+
username=user,
|
|
135
|
+
password=password,
|
|
136
|
+
dbname=database,
|
|
137
|
+
port=internal_port,
|
|
138
|
+
driver=None,
|
|
139
|
+
)
|
|
140
|
+
for key, value in env.items():
|
|
141
|
+
container.with_env(key, value)
|
|
142
|
+
for index, script in enumerate(init_scripts):
|
|
143
|
+
# SPEC §10.2: NN-name.sql, ordered.
|
|
144
|
+
container.with_volume_mapping(
|
|
145
|
+
str(script),
|
|
146
|
+
f"/docker-entrypoint-initdb.d/{index:02d}-{script.name}",
|
|
147
|
+
"ro",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
instance, bound = _start_or_attach(container, reuse_name=reuse_name)
|
|
151
|
+
if bound:
|
|
152
|
+
host, port = _read_existing_host_port(instance, internal_port)
|
|
153
|
+
return ContainerHandle(
|
|
154
|
+
host=host,
|
|
155
|
+
port=port,
|
|
156
|
+
name=reuse_name,
|
|
157
|
+
is_bound_to_existing=True,
|
|
158
|
+
_instance=instance,
|
|
159
|
+
_owns_instance=False,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
start_or_raise(instance, image)
|
|
163
|
+
if reuse_name is None:
|
|
164
|
+
register_atexit_stop(instance)
|
|
165
|
+
|
|
166
|
+
host = instance.get_container_host_ip()
|
|
167
|
+
port = int(instance.get_exposed_port(internal_port))
|
|
168
|
+
return ContainerHandle(
|
|
169
|
+
host=_normalize_host(host),
|
|
170
|
+
port=port,
|
|
171
|
+
name=reuse_name,
|
|
172
|
+
is_bound_to_existing=False,
|
|
173
|
+
_instance=instance,
|
|
174
|
+
_owns_instance=reuse_name is None,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def start_redis(
|
|
179
|
+
*,
|
|
180
|
+
image: str,
|
|
181
|
+
internal_port: int,
|
|
182
|
+
reuse_name: str | None,
|
|
183
|
+
) -> ContainerHandle:
|
|
184
|
+
"""Construct + start (or attach to) a RedisContainer."""
|
|
185
|
+
from testcontainers.redis import RedisContainer
|
|
186
|
+
|
|
187
|
+
_check_daemon()
|
|
188
|
+
|
|
189
|
+
container = RedisContainer(image=image, port=internal_port)
|
|
190
|
+
instance, bound = _start_or_attach(container, reuse_name=reuse_name)
|
|
191
|
+
if bound:
|
|
192
|
+
host, port = _read_existing_host_port(instance, internal_port)
|
|
193
|
+
return ContainerHandle(
|
|
194
|
+
host=host,
|
|
195
|
+
port=port,
|
|
196
|
+
name=reuse_name,
|
|
197
|
+
is_bound_to_existing=True,
|
|
198
|
+
_instance=instance,
|
|
199
|
+
_owns_instance=False,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
start_or_raise(instance, image)
|
|
203
|
+
if reuse_name is None:
|
|
204
|
+
register_atexit_stop(instance)
|
|
205
|
+
|
|
206
|
+
host = instance.get_container_host_ip()
|
|
207
|
+
port = int(instance.get_exposed_port(internal_port))
|
|
208
|
+
return ContainerHandle(
|
|
209
|
+
host=_normalize_host(host),
|
|
210
|
+
port=port,
|
|
211
|
+
name=reuse_name,
|
|
212
|
+
is_bound_to_existing=False,
|
|
213
|
+
_instance=instance,
|
|
214
|
+
_owns_instance=reuse_name is None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _normalize_host(host: str) -> str:
|
|
219
|
+
if host in ("", "0.0.0.0"):
|
|
220
|
+
return "localhost"
|
|
221
|
+
return host
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _read_existing_host_port(instance: Any, internal_port: int) -> tuple[str, int]:
|
|
225
|
+
"""Read host/port from a docker-py container the testcontainers wrapper holds.
|
|
226
|
+
|
|
227
|
+
``bind_to_running`` sets ``instance._container`` to the docker-py
|
|
228
|
+
Container object; that's where the port mapping lives.
|
|
229
|
+
"""
|
|
230
|
+
docker_container = getattr(instance, "_container", None)
|
|
231
|
+
if docker_container is None: # pragma: no cover — defensive
|
|
232
|
+
host = instance.get_container_host_ip()
|
|
233
|
+
port = int(instance.get_exposed_port(internal_port))
|
|
234
|
+
return _normalize_host(host), port
|
|
235
|
+
ports = docker_container.attrs["NetworkSettings"]["Ports"]
|
|
236
|
+
binding = (ports.get(f"{internal_port}/tcp") or [{}])[0]
|
|
237
|
+
host = binding.get("HostIp", "localhost")
|
|
238
|
+
return _normalize_host(host), int(binding["HostPort"])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def reuse_name(service_slug: str) -> str:
|
|
242
|
+
"""Compose a reuse-name for the eager-start path. Single-shared per controller."""
|
|
243
|
+
return reuse_name_for(service_slug)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
__all__ = [
|
|
247
|
+
"ContainerHandle",
|
|
248
|
+
"DockerNotRunningError",
|
|
249
|
+
"reuse_name",
|
|
250
|
+
"start_postgres",
|
|
251
|
+
"start_redis",
|
|
252
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Public exception classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PytestTestcontainersDjangoError(Exception):
|
|
7
|
+
"""Base class for all errors raised by this plugin."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(PytestTestcontainersDjangoError):
|
|
11
|
+
"""Raised when configuration is invalid (SPEC §5.6)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaselineMissingError(PytestTestcontainersDjangoError):
|
|
15
|
+
"""Raised when ``use_django_pg_baseline = true`` but ``django-pg-baseline`` is unavailable."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReuseStaleContainerError(PytestTestcontainersDjangoError):
|
|
19
|
+
"""Raised when reuse mode finds a pre-existing container that can't be revived
|
|
20
|
+
(e.g. status ``dead``/``removing``). Starting a fresh container with the same
|
|
21
|
+
name would fail with a Docker name conflict, so we surface a clear actionable
|
|
22
|
+
error instead.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BaselineMissingError",
|
|
28
|
+
"ConfigError",
|
|
29
|
+
"PytestTestcontainersDjangoError",
|
|
30
|
+
"ReuseStaleContainerError",
|
|
31
|
+
]
|