Skip to content

Commit 58459a1

Browse files
feat(compose): add structured container inspect information (#897)
Summary This PR adds the ability to retrieve detailed container information from docker inspect in a structured format for ComposeContainer objects, with lazy loading and caching for optimal performance. Related with #857 --------- Co-authored-by: Roy Moore <roy@moore.co.il>
1 parent fed65fe commit 58459a1

File tree

10 files changed

+1160
-17
lines changed

10 files changed

+1160
-17
lines changed

conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,5 @@
168168
nitpick_ignore = [
169169
("py:class", "typing_extensions.Self"),
170170
("py:class", "docker.models.containers.ExecResult"),
171+
("py:class", "testcontainers.core.docker_client.ContainerInspectInfo"),
171172
]

core/testcontainers/compose/compose.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import sys
2-
from dataclasses import asdict, dataclass, field, fields, is_dataclass
2+
from dataclasses import asdict, dataclass, field
33
from functools import cached_property
44
from json import loads
55
from logging import getLogger, warning
@@ -11,29 +11,16 @@
1111
from types import TracebackType
1212
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast
1313

14-
from testcontainers.core.docker_client import get_docker_host_hostname
14+
from testcontainers.core.docker_client import DockerClient, get_docker_host_hostname
1515
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
16+
from testcontainers.core.inspect import ContainerInspectInfo, _ignore_properties
1617
from testcontainers.core.waiting_utils import WaitStrategy
1718

18-
_IPT = TypeVar("_IPT")
1919
_WARNINGS = {"DOCKER_COMPOSE_GET_CONFIG": "get_config is experimental, see testcontainers/testcontainers-python#669"}
2020

2121
logger = getLogger(__name__)
2222

2323

24-
def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT:
25-
"""omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true)
26-
27-
https://gist.github.com/alexanderankin/2a4549ac03554a31bef6eaaf2eaf7fd5"""
28-
if isinstance(dict_, cls):
29-
return dict_
30-
if not is_dataclass(cls):
31-
raise TypeError(f"Expected a dataclass type, got {cls}")
32-
class_fields = {f.name for f in fields(cls)}
33-
filtered = {k: v for k, v in dict_.items() if k in class_fields}
34-
return cls(**filtered)
35-
36-
3724
@dataclass
3825
class PublishedPortModel:
3926
"""
@@ -93,6 +80,7 @@ class ComposeContainer:
9380
ExitCode: Optional[int] = None
9481
Publishers: list[PublishedPortModel] = field(default_factory=list)
9582
_docker_compose: Optional["DockerCompose"] = field(default=None, init=False, repr=False)
83+
_cached_container_info: Optional[ContainerInspectInfo] = field(default=None, init=False, repr=False)
9684

9785
def __post_init__(self) -> None:
9886
if self.Publishers:
@@ -159,6 +147,28 @@ def reload(self) -> None:
159147
# each time through get_container(), but we need this method for compatibility
160148
pass
161149

150+
def get_container_info(self) -> Optional[ContainerInspectInfo]:
151+
"""Get container information via docker inspect (lazy loaded).
152+
153+
Returns:
154+
Container inspect information or None if container is not started.
155+
"""
156+
if self._cached_container_info is not None:
157+
return self._cached_container_info
158+
159+
if not self._docker_compose or not self.ID:
160+
return None
161+
162+
try:
163+
docker_client = self._docker_compose._get_docker_client()
164+
self._cached_container_info = docker_client.get_container_inspect_info(self.ID)
165+
166+
except Exception as e:
167+
logger.warning(f"Failed to get container info for {self.ID}: {e}")
168+
self._cached_container_info = None
169+
170+
return self._cached_container_info
171+
162172
@property
163173
def status(self) -> str:
164174
"""Get container status for compatibility with wait strategies."""
@@ -233,6 +243,7 @@ class DockerCompose:
233243
quiet_pull: bool = False
234244
quiet_build: bool = False
235245
_wait_strategies: Optional[dict[str, Any]] = field(default=None, init=False, repr=False)
246+
_docker_client: Optional[DockerClient] = field(default=None, init=False, repr=False)
236247

237248
def __post_init__(self) -> None:
238249
if isinstance(self.compose_file_name, str):
@@ -597,3 +608,9 @@ def wait_for(self, url: str) -> "DockerCompose":
597608
with urlopen(url) as response:
598609
response.read()
599610
return self
611+
612+
def _get_docker_client(self) -> DockerClient:
613+
"""Get Docker client instance."""
614+
if self._docker_client is None:
615+
self._docker_client = DockerClient()
616+
return self._docker_client

core/testcontainers/core/container.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from testcontainers.core.config import testcontainers_config as c
2020
from testcontainers.core.docker_client import DockerClient
2121
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
22+
from testcontainers.core.inspect import ContainerInspectInfo
2223
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
2324
from testcontainers.core.network import Network
2425
from testcontainers.core.transferable import Transferable, TransferSpec, build_transfer_tar
@@ -104,6 +105,7 @@ def __init__(
104105

105106
self._kwargs = kwargs
106107
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy
108+
self._cached_container_info: Optional[ContainerInspectInfo] = None
107109

108110
self._transferable_specs: list[TransferSpec] = []
109111
if transferables:
@@ -328,6 +330,27 @@ def exec(self, command: Union[str, list[str]]) -> ExecResult:
328330
raise ContainerStartException("Container should be started before executing a command")
329331
return self._container.exec_run(command)
330332

333+
def get_container_info(self) -> Optional[ContainerInspectInfo]:
334+
"""Get container information via docker inspect (lazy loaded).
335+
336+
Returns:
337+
Container inspect information or None if container is not started.
338+
"""
339+
if self._cached_container_info is not None:
340+
return self._cached_container_info
341+
342+
if not self._container:
343+
return None
344+
345+
try:
346+
self._cached_container_info = self.get_docker_client().get_container_inspect_info(self._container.id)
347+
348+
except Exception as e:
349+
logger.warning(f"Failed to get container info for {self._container.id}: {e}")
350+
self._cached_container_info = None
351+
352+
return self._cached_container_info
353+
331354
def _configure(self) -> None:
332355
# placeholder if subclasses want to define this and use the default start method
333356
pass

core/testcontainers/core/docker_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from testcontainers.core.auth import DockerAuthInfo, parse_docker_auth_config
3131
from testcontainers.core.config import ConnectionMode
3232
from testcontainers.core.config import testcontainers_config as c
33+
from testcontainers.core.inspect import ContainerInspectInfo
3334
from testcontainers.core.labels import SESSION_ID, create_labels
3435

3536
if TYPE_CHECKING:
@@ -275,6 +276,11 @@ def client_networks_create(self, name: str, param: dict[str, Any]) -> "DockerNet
275276
labels = create_labels("", param.get("labels"))
276277
return self.client.networks.create(name, **{**param, "labels": labels})
277278

279+
def get_container_inspect_info(self, container_id: str) -> "ContainerInspectInfo":
280+
"""Get container inspect information with fresh data."""
281+
container = self.client.containers.get(container_id)
282+
return ContainerInspectInfo.from_dict(container.attrs)
283+
278284

279285
def get_docker_host() -> Optional[str]:
280286
host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")

0 commit comments

Comments
 (0)