testcontainers-understudy 0.1.2__tar.gz

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,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: testcontainers-understudy
3
+ Version: 0.1.2
4
+ Summary: Testcontainers module for understudy, the local AWS emulator
5
+ License-Expression: Apache-2.0
6
+ Requires-Dist: testcontainers>=4.15,<5
7
+ Requires-Python: >=3.10
8
+ Project-URL: Homepage, https://github.com/sustinbebustin/understudy/tree/main/testcontainers/python
9
+ Project-URL: Source, https://github.com/sustinbebustin/understudy
10
+ Description-Content-Type: text/markdown
11
+
12
+ # testcontainers-understudy
13
+
14
+ Runs [understudy](https://github.com/sustinbebustin/understudy), the local AWS emulator, as a [Testcontainers for Python](https://testcontainers-python.readthedocs.io/) module.
15
+
16
+ It is released with understudy: version `X.Y.Z` of this package defaults to the image `ghcr.io/sustinbebustin/understudy:X.Y.Z`. Nothing is published yet, so build the image with `just image`, pass `understudy:dev`, and install the package from a clone, here at `../understudy`:
17
+
18
+ ```sh
19
+ uv add --dev ../understudy/testcontainers/python
20
+ ```
21
+
22
+ It needs Python 3.10 or later and `testcontainers>=4.15,<5`.
23
+
24
+ ## Use
25
+
26
+ ```python
27
+ import boto3
28
+ import pytest
29
+ from testcontainers_understudy import UnderstudyContainer
30
+
31
+
32
+ @pytest.fixture(scope="session")
33
+ def understudy():
34
+ with UnderstudyContainer("understudy:dev") as u:
35
+ yield u
36
+
37
+
38
+ @pytest.fixture(autouse=True)
39
+ def aws_env(understudy, monkeypatch):
40
+ for name, value in understudy.get_env().items(): # AWS_ENDPOINT_URL, test credentials, us-east-1
41
+ monkeypatch.setenv(name, value)
42
+ yield
43
+ understudy.reset()
44
+
45
+
46
+ def test_orders_queue():
47
+ sqs = boto3.client("sqs")
48
+ url = sqs.create_queue(QueueName="orders")["QueueUrl"]
49
+ sqs.send_message(QueueUrl=url, MessageBody="order-1")
50
+ assert [m["Body"] for m in sqs.receive_message(QueueUrl=url)["Messages"]] == ["order-1"]
51
+ ```
52
+
53
+ `start()` publishes port 4566 on `127.0.0.1` only and returns once `GET /_understudy/v1/health` answers 200 with `"ready": true`; if understudy exits first, it raises with understudy's log. A `start()` that raises once the container exists has already stopped and removed it. `stop()`, and leaving the `with` block, stops understudy with a 10-second grace period, which lets it remove the Lambda and DynamoDB Local containers it started, and every such container carries the session's label, so Ryuk removes them too.
54
+
55
+ ## Options
56
+
57
+ | Method | Effect |
58
+ |---|---|
59
+ | `with_docker_socket()` | mounts the Docker socket and adds its group (0 on Docker Desktop, whose VM owns the socket), for Lambda and DynamoDB Local; `start()` raises `UnderstudyDockerError` unless understudy then reports Docker enabled |
60
+ | `with_docker_socket(gid=n)` | the same, with the group given, for a daemon that is not a local unix socket |
61
+ | `with_clock(frozen_at=dt, scale=f)` | the emulator clock; `frozen_at` must be timezone-aware |
62
+ | `with_seed(n)` | repeatable generated identifiers |
63
+ | `with_sigv4_verify()` | verify request signatures |
64
+ | `with_snapshot(path)` | load a snapshot archive at start |
65
+ | `with_admin_token()` | a random admin token, sent by the wait and the helpers |
66
+ | `with_published_on(addr)` | publish on another address; beyond loopback only with `with_admin_token()`; a loopback address other than `127.0.0.1` is refused, since the endpoint never dials it |
67
+
68
+ `with_env` sets any other `UNDERSTUDY_*` setting. `settle(timeout)`, `advance_clock(by)` and `reset()` wrap the common admin calls and raise `UnderstudyAdminError` on an error answer. Misconfigurations raise `UnderstudyConfigError`, before any container is created. The exception is a missing snapshot archive: `with_snapshot` raises `FileNotFoundError` for it.
69
+
70
+ The [Testcontainers guide](https://github.com/sustinbebustin/understudy/blob/main/docs/guide/testcontainers-modules.md) covers Lambda, Terraform and cleanup in more detail.
71
+
72
+ ## Development
73
+
74
+ ```sh
75
+ just image
76
+ testcontainers/python/run.sh # uv sync, ruff check and format check, sealed pytest against understudy:dev
77
+ ```
78
+
79
+ `UNDERSTUDY_IMAGE` selects another image; extra arguments go to pytest.
@@ -0,0 +1,68 @@
1
+ # testcontainers-understudy
2
+
3
+ Runs [understudy](https://github.com/sustinbebustin/understudy), the local AWS emulator, as a [Testcontainers for Python](https://testcontainers-python.readthedocs.io/) module.
4
+
5
+ It is released with understudy: version `X.Y.Z` of this package defaults to the image `ghcr.io/sustinbebustin/understudy:X.Y.Z`. Nothing is published yet, so build the image with `just image`, pass `understudy:dev`, and install the package from a clone, here at `../understudy`:
6
+
7
+ ```sh
8
+ uv add --dev ../understudy/testcontainers/python
9
+ ```
10
+
11
+ It needs Python 3.10 or later and `testcontainers>=4.15,<5`.
12
+
13
+ ## Use
14
+
15
+ ```python
16
+ import boto3
17
+ import pytest
18
+ from testcontainers_understudy import UnderstudyContainer
19
+
20
+
21
+ @pytest.fixture(scope="session")
22
+ def understudy():
23
+ with UnderstudyContainer("understudy:dev") as u:
24
+ yield u
25
+
26
+
27
+ @pytest.fixture(autouse=True)
28
+ def aws_env(understudy, monkeypatch):
29
+ for name, value in understudy.get_env().items(): # AWS_ENDPOINT_URL, test credentials, us-east-1
30
+ monkeypatch.setenv(name, value)
31
+ yield
32
+ understudy.reset()
33
+
34
+
35
+ def test_orders_queue():
36
+ sqs = boto3.client("sqs")
37
+ url = sqs.create_queue(QueueName="orders")["QueueUrl"]
38
+ sqs.send_message(QueueUrl=url, MessageBody="order-1")
39
+ assert [m["Body"] for m in sqs.receive_message(QueueUrl=url)["Messages"]] == ["order-1"]
40
+ ```
41
+
42
+ `start()` publishes port 4566 on `127.0.0.1` only and returns once `GET /_understudy/v1/health` answers 200 with `"ready": true`; if understudy exits first, it raises with understudy's log. A `start()` that raises once the container exists has already stopped and removed it. `stop()`, and leaving the `with` block, stops understudy with a 10-second grace period, which lets it remove the Lambda and DynamoDB Local containers it started, and every such container carries the session's label, so Ryuk removes them too.
43
+
44
+ ## Options
45
+
46
+ | Method | Effect |
47
+ |---|---|
48
+ | `with_docker_socket()` | mounts the Docker socket and adds its group (0 on Docker Desktop, whose VM owns the socket), for Lambda and DynamoDB Local; `start()` raises `UnderstudyDockerError` unless understudy then reports Docker enabled |
49
+ | `with_docker_socket(gid=n)` | the same, with the group given, for a daemon that is not a local unix socket |
50
+ | `with_clock(frozen_at=dt, scale=f)` | the emulator clock; `frozen_at` must be timezone-aware |
51
+ | `with_seed(n)` | repeatable generated identifiers |
52
+ | `with_sigv4_verify()` | verify request signatures |
53
+ | `with_snapshot(path)` | load a snapshot archive at start |
54
+ | `with_admin_token()` | a random admin token, sent by the wait and the helpers |
55
+ | `with_published_on(addr)` | publish on another address; beyond loopback only with `with_admin_token()`; a loopback address other than `127.0.0.1` is refused, since the endpoint never dials it |
56
+
57
+ `with_env` sets any other `UNDERSTUDY_*` setting. `settle(timeout)`, `advance_clock(by)` and `reset()` wrap the common admin calls and raise `UnderstudyAdminError` on an error answer. Misconfigurations raise `UnderstudyConfigError`, before any container is created. The exception is a missing snapshot archive: `with_snapshot` raises `FileNotFoundError` for it.
58
+
59
+ The [Testcontainers guide](https://github.com/sustinbebustin/understudy/blob/main/docs/guide/testcontainers-modules.md) covers Lambda, Terraform and cleanup in more detail.
60
+
61
+ ## Development
62
+
63
+ ```sh
64
+ just image
65
+ testcontainers/python/run.sh # uv sync, ruff check and format check, sealed pytest against understudy:dev
66
+ ```
67
+
68
+ `UNDERSTUDY_IMAGE` selects another image; extra arguments go to pytest.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "testcontainers-understudy"
3
+ version = "0.1.2"
4
+ description = "Testcontainers module for understudy, the local AWS emulator"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "Apache-2.0"
8
+ dependencies = ["testcontainers>=4.15,<5"]
9
+
10
+ [project.urls]
11
+ Homepage = "https://github.com/sustinbebustin/understudy/tree/main/testcontainers/python"
12
+ Source = "https://github.com/sustinbebustin/understudy"
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "boto3>=1.43",
17
+ "pytest>=9",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.13.4,<0.14"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
26
+ addopts = "-ra --strict-markers"
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "testcontainers-understudy"
3
+ version = "0.1.2"
4
+ description = "Testcontainers module for understudy, the local AWS emulator"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "Apache-2.0"
8
+ dependencies = ["testcontainers>=4.15,<5"]
9
+
10
+ [project.urls]
11
+ Homepage = "https://github.com/sustinbebustin/understudy/tree/main/testcontainers/python"
12
+ Source = "https://github.com/sustinbebustin/understudy"
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "boto3>=1.43",
17
+ "pytest>=9",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.13.4,<0.14"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
26
+ addopts = "-ra --strict-markers"
@@ -0,0 +1,513 @@
1
+ """Testcontainers module for understudy, the local AWS emulator.
2
+
3
+ ``UnderstudyContainer`` publishes port 4566 on 127.0.0.1 only, waits for
4
+ ``GET /_understudy/v1/health`` to answer 200 with ``"ready": true``, labels
5
+ every container understudy starts with the session's Ryuk filter label, and
6
+ stops with a 10 s grace so understudy removes its own containers first
7
+ (testcontainers.md 8).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.metadata
13
+ import ipaddress
14
+ import json
15
+ import os
16
+ import secrets
17
+ import stat
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timedelta
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from docker.errors import NotFound
27
+ from docker.transport import UnixHTTPAdapter
28
+ from docker.types import Mount
29
+ from testcontainers.core.config import testcontainers_config
30
+ from testcontainers.core.container import DockerContainer
31
+ from testcontainers.core.docker_client import get_docker_host
32
+ from testcontainers.core.exceptions import ContainerStartException
33
+ from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
34
+ from testcontainers.core.wait_strategies import HttpWaitStrategy
35
+ from testcontainers.core.waiting_utils import WaitStrategyTarget
36
+
37
+ __all__ = [
38
+ "DEFAULT_IMAGE",
39
+ "UnderstudyAdminError",
40
+ "UnderstudyConfigError",
41
+ "UnderstudyContainer",
42
+ "UnderstudyDockerError",
43
+ "__version__",
44
+ ]
45
+
46
+ # pyproject.toml is the one place the version is written (scripts/bump-version).
47
+ __version__ = importlib.metadata.version("testcontainers-understudy")
48
+
49
+ # The release image of the understudy version this package is released
50
+ # with, in lockstep (testcontainers.md 8.8).
51
+ DEFAULT_IMAGE = f"ghcr.io/sustinbebustin/understudy:{__version__}"
52
+
53
+ # understudy's listener in the container (testcontainers.md 3.2).
54
+ _API_PORT = 4566
55
+ _API_PORT_KEY = f"{_API_PORT}/tcp"
56
+ _ADMIN_PREFIX = "/_understudy/v1"
57
+ # The wait's bound, though understudy is ready in milliseconds: it bounds only
58
+ # image pulls and failures. tc-py's own default is 120 s (testcontainers.md 8.3).
59
+ _STARTUP_TIMEOUT_SECONDS = 60
60
+ _POLL_INTERVAL_SECONDS = 0.1
61
+ # tc-py's stop is a force remove with no drain; understudy needs the grace to
62
+ # remove its own containers (testcontainers.md 8.6, finding 4).
63
+ _STOP_GRACE_SECONDS = 10
64
+ # How long an admin call may take beyond the time it asks understudy to wait.
65
+ _ADMIN_CALL_SECONDS = 30.0
66
+ _LOOPBACK = ipaddress.ip_address("127.0.0.1")
67
+ _SOCKET_TARGET = "/var/run/docker.sock"
68
+ _SNAPSHOT_PATH = "/snapshot/archive"
69
+
70
+
71
+ class UnderstudyConfigError(ValueError):
72
+ """A module setting refused before any container is created (testcontainers.md 9.5)."""
73
+
74
+
75
+ class UnderstudyDockerError(RuntimeError):
76
+ """A mounted Docker socket understudy reports it cannot use after start (testcontainers.md 8.5)."""
77
+
78
+ def __init__(self, status: str, reason: str) -> None:
79
+ self.status = status
80
+ self.reason = reason
81
+ super().__init__(
82
+ f"understudy: the Docker socket is mounted but understudy reports Docker {status}: {reason}; "
83
+ "Lambda and DynamoDB will not run. Check the socket group (with_docker_socket(gid=...)) "
84
+ "or run without with_docker_socket()."
85
+ )
86
+
87
+
88
+ class UnderstudyAdminError(RuntimeError):
89
+ """An admin API call understudy answered outside 2xx, with its error body."""
90
+
91
+ def __init__(self, status: int, code: str, message: str, hint: str = "") -> None:
92
+ self.status = status
93
+ self.code = code
94
+ self.message = message
95
+ self.hint = hint
96
+ text = f"understudy: admin API answered {status} {code}: {message}"
97
+ super().__init__(f"{text}; {hint}" if hint else text)
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class _DockerSocket:
102
+ """A with_docker_socket request: the group to add, or None to read the local socket's."""
103
+
104
+ gid: int | None
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class _SocketMount:
109
+ """A resolved with_docker_socket request: the daemon-side socket path and its numeric group."""
110
+
111
+ source: str
112
+ gid: int
113
+
114
+
115
+ class UnderstudyContainer(DockerContainer):
116
+ """understudy in a container, configured by the with_* methods below.
117
+
118
+ ``start()`` refuses, before creating anything, publishing beyond loopback
119
+ without ``with_admin_token()`` and a Docker socket whose group it cannot
120
+ read; both raise UnderstudyConfigError. Argument shapes are checked when
121
+ each option is called, so option order never changes the outcome
122
+ (testcontainers.md 9.5).
123
+ """
124
+
125
+ def __init__(self, image: str = DEFAULT_IMAGE, **kwargs: Any) -> None:
126
+ super().__init__(image=image, **kwargs)
127
+ self._token: str | None = None
128
+ self._published_on: ipaddress.IPv4Address | ipaddress.IPv6Address = _LOOPBACK
129
+ self._socket: _DockerSocket | None = None
130
+ # Set by start() once the socket request is resolved, for _configure().
131
+ self._mount: _SocketMount | None = None
132
+
133
+ def with_docker_socket(self, gid: int | None = None) -> UnderstudyContainer:
134
+ """Mount the daemon's Docker socket at /var/run/docker.sock and add its numeric group.
135
+
136
+ Lambda and DynamoDB Local need it (testcontainers.md 8.5). Without
137
+ ``gid``, start() reads the group of the local unix socket the Docker
138
+ client uses, and refuses any other daemon. A group name cannot be
139
+ given: the image has no /etc/group to resolve it. A later call
140
+ without ``gid`` keeps a group given earlier. After start, a socket
141
+ understudy does not report enabled raises UnderstudyDockerError.
142
+ """
143
+ if gid is not None and (isinstance(gid, bool) or not isinstance(gid, int) or gid < 0):
144
+ msg = f"understudy: the Docker socket group must be a non-negative integer group ID, not {gid!r}."
145
+ raise UnderstudyConfigError(msg)
146
+ if gid is not None or self._socket is None:
147
+ self._socket = _DockerSocket(gid)
148
+ return self
149
+
150
+ def with_clock(self, *, frozen_at: datetime | None = None, scale: float | None = None) -> UnderstudyContainer:
151
+ """Freeze the emulator clock at ``frozen_at``, or scale it by ``scale``.
152
+
153
+ Sets UNDERSTUDY_CLOCK=frozen@<RFC 3339> and UNDERSTUDY_CLOCK_SCALE
154
+ (testcontainers.md 8.2). understudy checks the scale's range and
155
+ refuses a frozen clock with a scale itself; start() then raises with
156
+ the container's log (testcontainers.md 9.5).
157
+ """
158
+ if frozen_at is not None and frozen_at.utcoffset() is None:
159
+ msg = (
160
+ "understudy: frozen_at must be a timezone-aware datetime, "
161
+ "such as datetime(2026, 1, 1, tzinfo=timezone.utc)."
162
+ )
163
+ raise UnderstudyConfigError(msg)
164
+ if frozen_at is not None:
165
+ self.with_env("UNDERSTUDY_CLOCK", "frozen@" + frozen_at.isoformat())
166
+ if scale is not None:
167
+ self.with_env("UNDERSTUDY_CLOCK_SCALE", str(scale))
168
+ return self
169
+
170
+ def with_seed(self, seed: int) -> UnderstudyContainer:
171
+ """Set UNDERSTUDY_SEED; understudy checks its range itself (testcontainers.md 9.5)."""
172
+ self.with_env("UNDERSTUDY_SEED", str(seed))
173
+ return self
174
+
175
+ def with_sigv4_verify(self) -> UnderstudyContainer:
176
+ """Set UNDERSTUDY_SIGV4=verify, so understudy checks every request signature."""
177
+ self.with_env("UNDERSTUDY_SIGV4", "verify")
178
+ return self
179
+
180
+ def with_snapshot(self, path: str | os.PathLike[str]) -> UnderstudyContainer:
181
+ """Load the snapshot archive at ``path`` at start.
182
+
183
+ The file is copied to /snapshot/archive in the container, mode 0644,
184
+ and UNDERSTUDY_LOAD_SNAPSHOT names it (testcontainers.md 8.2). A
185
+ missing file raises FileNotFoundError here, before any container.
186
+ """
187
+ archive = Path(path).absolute()
188
+ if stat.S_ISDIR(archive.stat().st_mode):
189
+ msg = (
190
+ f"understudy: with_snapshot({os.fspath(path)!r}) names a directory; "
191
+ "pass the archive file, such as one POST /_understudy/v1/snapshots answered with."
192
+ )
193
+ raise UnderstudyConfigError(msg)
194
+ self._transferable_specs = [spec for spec in self._transferable_specs if spec[1] != _SNAPSHOT_PATH]
195
+ self.with_copy_into_container(archive, _SNAPSHOT_PATH, 0o644)
196
+ self.with_env("UNDERSTUDY_LOAD_SNAPSHOT", _SNAPSHOT_PATH)
197
+ return self
198
+
199
+ def with_admin_token(self) -> UnderstudyContainer:
200
+ """Guard the admin API with a random token the wait and the helpers send.
201
+
202
+ Sets UNDERSTUDY_ADMIN_TOKEN to 64 hex characters from 32 random bytes
203
+ and UNDERSTUDY_ALLOW_UNAUTHENTICATED_AWS_API=true, which understudy
204
+ requires beside the token; health then answers 401 without it
205
+ (testcontainers.md 8.2, 3.3).
206
+ """
207
+ self._token = secrets.token_hex(32)
208
+ self.with_env("UNDERSTUDY_ADMIN_TOKEN", self._token)
209
+ self.with_env("UNDERSTUDY_ALLOW_UNAUTHENTICATED_AWS_API", "true")
210
+ return self
211
+
212
+ def with_published_on(self, addr: str) -> UnderstudyContainer:
213
+ """Publish port 4566 on ``addr`` instead of 127.0.0.1.
214
+
215
+ An address beyond loopback, 0.0.0.0 and :: included, needs
216
+ with_admin_token(), or start() refuses it (testcontainers.md 8.4).
217
+ Any other loopback address is refused here: the endpoint and the
218
+ wait dial the daemon's host, 127.0.0.1 for a local daemon, so they
219
+ could never reach it (testcontainers.md 8.2, 9.5).
220
+ """
221
+ try:
222
+ published_on = ipaddress.ip_address(addr)
223
+ except ValueError:
224
+ msg = f"understudy: with_published_on({addr!r}) is not an IP address; pass one such as 127.0.0.1."
225
+ raise UnderstudyConfigError(msg) from None
226
+ if published_on.is_loopback and published_on != _LOOPBACK:
227
+ msg = (
228
+ f"understudy: with_published_on({addr!r}) is a loopback address the endpoint never dials: the "
229
+ "endpoint and the readiness wait reach port 4566 at the Docker host's address, 127.0.0.1 for a "
230
+ "local daemon; publish on 127.0.0.1, or on 0.0.0.0 with with_admin_token()."
231
+ )
232
+ raise UnderstudyConfigError(msg)
233
+ self._published_on = published_on
234
+ return self
235
+
236
+ def start(self) -> UnderstudyContainer:
237
+ """Start understudy and return once it reports ready (testcontainers.md 8.3).
238
+
239
+ A container that exits first raises ContainerStartException with its
240
+ log. With the Docker socket, a socket understudy does not report
241
+ enabled raises UnderstudyDockerError. Whatever fails once the
242
+ container exists, start() stops and removes it before raising, so an
243
+ explicit start() outside a with block leaves nothing behind.
244
+ """
245
+ self._check_exposure()
246
+ self._mount = self._resolve_socket()
247
+ try:
248
+ super().start()
249
+ if self._mount is not None:
250
+ self._check_docker()
251
+ except BaseException:
252
+ self.stop()
253
+ raise
254
+ return self
255
+
256
+ def stop(self, force: bool = True, delete_volume: bool = True) -> None:
257
+ """Stop with a 10 s grace, then remove the container and its volumes.
258
+
259
+ The grace lets understudy remove the containers it started before it
260
+ exits (testcontainers.md 8.6). A container already gone counts as
261
+ removed.
262
+ """
263
+ container = self._container
264
+ if container is not None:
265
+ try:
266
+ container.stop(timeout=_STOP_GRACE_SECONDS)
267
+ container.remove(force=force, v=delete_volume)
268
+ except NotFound:
269
+ pass
270
+ self._container = None
271
+ self.get_docker_client().client.close()
272
+
273
+ def get_endpoint(self) -> str:
274
+ """``http://<host>:<mapped 4566>``, with localhost as 127.0.0.1 (testcontainers.md 8.2)."""
275
+ return _endpoint_url(self.get_container_host_ip(), self.get_exposed_port(_API_PORT))
276
+
277
+ def get_env(self) -> dict[str, str]:
278
+ """The environment that points an SDK at the container, as understudytest.Env (testcontainers.md 3.8)."""
279
+ return {
280
+ "AWS_ENDPOINT_URL": self.get_endpoint(),
281
+ "AWS_ACCESS_KEY_ID": "test",
282
+ "AWS_SECRET_ACCESS_KEY": "test",
283
+ "AWS_REGION": "us-east-1",
284
+ "AWS_DEFAULT_REGION": "us-east-1",
285
+ }
286
+
287
+ def settle(self, timeout: float) -> None:
288
+ """Wait up to ``timeout`` seconds for understudy's tracked work to finish.
289
+
290
+ Sent as whole milliseconds to GET /_understudy/v1/settle
291
+ (testcontainers.md 3.7); understudy answers 412 condition_not_met on
292
+ timeout (admin.ConditionNotMet in admin/errors.go).
293
+ """
294
+ self._call("GET", f"/settle?timeout={int(timeout * 1000)}ms", timeout=max(timeout, 0) + _ADMIN_CALL_SECONDS)
295
+
296
+ def advance_clock(self, by: timedelta) -> None:
297
+ """Move the emulator clock forward by ``by``, sent in whole microseconds (testcontainers.md 3.7)."""
298
+ self._call("POST", "/clock", {"op": "advance", "by": f"{by // timedelta(microseconds=1)}us"})
299
+
300
+ def reset(self) -> None:
301
+ """Drop all service state, keeping the clock and the fault rules (testcontainers.md 3.7)."""
302
+ self._call("POST", "/reset", {})
303
+
304
+ def _configure(self) -> None:
305
+ # The module owns port 4566's binding; a bare "4566" key from ports=
306
+ # or with_exposed_ports would otherwise publish it on every interface.
307
+ self.ports.pop(str(_API_PORT), None)
308
+ # SAFETY: docker-py binds a (host_ip, host_port) tuple as HostIp and
309
+ # HostPort, and tc-py passes self.ports to it unchanged, but annotates
310
+ # the values as Optional[int] (testcontainers.md 5.3, 8.4).
311
+ self.ports[_API_PORT_KEY] = (str(self._published_on), 0) # type: ignore[assignment]
312
+ self._wait_strategy = _ReadyWait(self._token)
313
+ # The session's Ryuk filter label goes on every container understudy
314
+ # starts, so Ryuk reaps them too; a value the user set wins
315
+ # (testcontainers.md 8.6, 5.4, 9.3).
316
+ if "UNDERSTUDY_DOCKER_LABELS" not in self.env:
317
+ self.env["UNDERSTUDY_DOCKER_LABELS"] = json.dumps({LABEL_SESSION_ID: SESSION_ID})
318
+ if self._mount is not None:
319
+ self._kwargs = {**self._kwargs, **self._mount_kwargs(self._mount)}
320
+
321
+ def _mount_kwargs(self, mount: _SocketMount) -> dict[str, list[Any]]:
322
+ """The user's mounts and groups with the socket's appended, never replaced.
323
+
324
+ A bind mount, unlike a volume binding, fails on a missing source
325
+ instead of creating a root-owned directory (testcontainers.md 3.4).
326
+ """
327
+ socket = Mount(target=_SOCKET_TARGET, source=mount.source, type="bind")
328
+ mounts = list(self._kwargs.get("mounts") or [])
329
+ if socket not in mounts:
330
+ mounts.append(socket)
331
+ groups = list(self._kwargs.get("group_add") or [])
332
+ if str(mount.gid) not in [str(g) for g in groups]:
333
+ groups.append(str(mount.gid))
334
+ return {"mounts": mounts, "group_add": groups}
335
+
336
+ def _check_exposure(self) -> None:
337
+ """Refuse publishing beyond loopback without the token (testcontainers.md 8.4, 9.5)."""
338
+ if not self._published_on.is_loopback and self._token is None:
339
+ msg = (
340
+ f"understudy: publishing port 4566 on {self._published_on} exposes the admin API, which has no "
341
+ "token in the container; add with_admin_token() or publish on 127.0.0.1."
342
+ )
343
+ raise UnderstudyConfigError(msg)
344
+
345
+ def _resolve_socket(self) -> _SocketMount | None:
346
+ """The socket to mount and its group, or None without with_docker_socket (testcontainers.md 8.5).
347
+
348
+ The source is the path tc-py mounts into Ryuk. The group is the one
349
+ given, else 0 on Docker Desktop, whose VM socket is root:root 0660,
350
+ else the st_gid of the local unix socket the client uses.
351
+ """
352
+ if self._socket is None:
353
+ return None
354
+ gid = self._socket.gid
355
+ if gid is None:
356
+ gid = self._local_socket_gid()
357
+ return _SocketMount(source=testcontainers_config.ryuk_docker_socket, gid=gid)
358
+
359
+ def _local_socket_gid(self) -> int:
360
+ api = self.get_docker_client().client.api
361
+ adapter = api.get_adapter(api.base_url)
362
+ if not isinstance(adapter, UnixHTTPAdapter):
363
+ daemon = get_docker_host() or api.base_url
364
+ msg = (
365
+ f"understudy: cannot read the group of the Docker socket on {daemon}, which is not a local unix "
366
+ "socket; pass with_docker_socket(gid=<gid>), the numeric group owning /var/run/docker.sock on the "
367
+ "daemon's host."
368
+ )
369
+ raise UnderstudyConfigError(msg)
370
+ # Docker Desktop mounts its VM's socket, not the host's (TC-01).
371
+ if "Docker Desktop" in self.get_docker_client().client.info().get("OperatingSystem", ""):
372
+ return 0
373
+ try:
374
+ return os.stat(adapter.socket_path).st_gid
375
+ except OSError as e:
376
+ msg = (
377
+ f"understudy: cannot read the group of the Docker socket {adapter.socket_path}: {e.strerror}; "
378
+ "pass with_docker_socket(gid=<gid>), the numeric group owning the socket."
379
+ )
380
+ raise UnderstudyConfigError(msg) from e
381
+
382
+ def _check_docker(self) -> None:
383
+ """Raise unless understudy reports Docker enabled: a mounted socket never fails silently.
384
+
385
+ (testcontainers.md 8.5, 9.7)
386
+ """
387
+ status, reason = _docker_state(self._call("GET", "/health"))
388
+ if status != "enabled":
389
+ raise UnderstudyDockerError(status, reason)
390
+
391
+ def _call(
392
+ self, method: str, path: str, body: dict[str, str] | None = None, timeout: float = _ADMIN_CALL_SECONDS
393
+ ) -> object:
394
+ """One admin API call, with the token when set; the decoded JSON answer, or None when empty."""
395
+ data = None if body is None else json.dumps(body).encode()
396
+ req = urllib.request.Request(self.get_endpoint() + _ADMIN_PREFIX + path, data=data, method=method)
397
+ if data is not None:
398
+ req.add_header("Content-Type", "application/json")
399
+ if self._token is not None:
400
+ req.add_header("Authorization", f"Bearer {self._token}")
401
+ try:
402
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
403
+ raw = resp.read()
404
+ except urllib.error.HTTPError as e:
405
+ raise _admin_error(e.code, e.read()) from None
406
+ return json.loads(raw) if raw else None
407
+
408
+
409
+ class _ReadyWait(HttpWaitStrategy):
410
+ """GET /_understudy/v1/health until it answers 200 with "ready": true (testcontainers.md 8.3).
411
+
412
+ Unlike HttpWaitStrategy, it checks the container on every poll, so an
413
+ understudy that refuses its settings and exits fails the start at once,
414
+ with its log, instead of after the timeout (testcontainers.md 5.2, 9.5).
415
+ Never the image HEALTHCHECK, an open port or a sleep (testcontainers.md 9.7).
416
+ """
417
+
418
+ def __init__(self, token: str | None) -> None:
419
+ super().__init__(_API_PORT, _ADMIN_PREFIX + "/health")
420
+ self.for_response_predicate(_reports_ready)
421
+ self.with_poll_interval(_POLL_INTERVAL_SECONDS)
422
+ self.with_startup_timeout(_STARTUP_TIMEOUT_SECONDS)
423
+ if token is not None:
424
+ self.with_header("Authorization", f"Bearer {token}")
425
+
426
+ def wait_until_ready(self, container: WaitStrategyTarget) -> None:
427
+ deadline = time.monotonic() + self._startup_timeout
428
+ headers = self._setup_headers()
429
+ url: str | None = None
430
+ while True:
431
+ _raise_if_exited(container)
432
+ if url is None:
433
+ url = self._health_url(container)
434
+ if url is not None and self._try_http_request(url, headers, None):
435
+ return
436
+ if time.monotonic() >= deadline:
437
+ msg = (
438
+ f"understudy: not ready within {self._startup_timeout:g} s: GET {url or self._path} never "
439
+ 'answered 200 with "ready": true. start() removes the container; tc-py attaches its log to '
440
+ "this error as a note (Python 3.11 and later)."
441
+ )
442
+ raise TimeoutError(msg)
443
+ time.sleep(self._poll_interval)
444
+
445
+ def _health_url(self, container: WaitStrategyTarget) -> str | None:
446
+ """The health URL, or None while the port mapping is not readable yet."""
447
+ try:
448
+ host = container.get_container_host_ip()
449
+ port = container.get_exposed_port(self._port)
450
+ except (TimeoutError, ConnectionError):
451
+ # The container left the running state between the exit check and
452
+ # the port lookup; the next poll's exit check reports it.
453
+ return None
454
+ return _endpoint_url(host, port) + self._path
455
+
456
+
457
+ def _raise_if_exited(container: WaitStrategyTarget) -> None:
458
+ """Raise ContainerStartException, with the container's log, once it is no longer running."""
459
+ wrapped = container.get_wrapped_container()
460
+ wrapped.reload()
461
+ if wrapped.status in ("created", "running"):
462
+ return
463
+ code = wrapped.attrs.get("State", {}).get("ExitCode", "unknown")
464
+ log = wrapped.logs().decode(errors="replace")
465
+ msg = (
466
+ f"understudy: the container stopped ({wrapped.status}, exit code {code}) before it reported ready; "
467
+ f"understudy refused its settings or could not start. Its log:\n{log}"
468
+ )
469
+ raise ContainerStartException(msg)
470
+
471
+
472
+ def _reports_ready(body: str) -> bool:
473
+ try:
474
+ health = json.loads(body)
475
+ except ValueError:
476
+ return False
477
+ return isinstance(health, dict) and health.get("ready") is True
478
+
479
+
480
+ def _docker_state(health: object) -> tuple[str, str]:
481
+ """Docker's status and reason in a health answer (testcontainers.md 3.1)."""
482
+ docker = health.get("docker") if isinstance(health, dict) else None
483
+ if not isinstance(docker, dict):
484
+ return "unreported", "the health answer has no docker field"
485
+ return str(docker.get("status", "")), str(docker.get("reason", ""))
486
+
487
+
488
+ def _endpoint_url(host: str, port: int) -> str:
489
+ """Join a daemon host, as tc-py reports it, and a port; an IPv6 host is bracketed."""
490
+ host = host.removeprefix("[").removesuffix("]")
491
+ if host == "localhost":
492
+ # SDKs address S3 path style only at an IP endpoint (testcontainers.md 8.2).
493
+ host = str(_LOOPBACK)
494
+ if ":" in host:
495
+ host = f"[{host}]"
496
+ return f"http://{host}:{port}"
497
+
498
+
499
+ def _admin_error(status: int, raw: bytes) -> UnderstudyAdminError:
500
+ """The error for a non-2xx admin answer: its envelope's fields, or the body text when it has none.
501
+
502
+ The envelope is {"error": {"code", "message", "hint"}} (ARCHITECTURE.md 17).
503
+ """
504
+ text = raw.decode(errors="replace")
505
+ try:
506
+ doc = json.loads(text)
507
+ except ValueError:
508
+ doc = None
509
+ err = doc.get("error") if isinstance(doc, dict) else None
510
+ if isinstance(err, dict) and isinstance(err.get("code"), str) and isinstance(err.get("message"), str):
511
+ hint = err.get("hint")
512
+ return UnderstudyAdminError(status, err["code"], err["message"], hint if isinstance(hint, str) else "")
513
+ return UnderstudyAdminError(status, "", text)