Skip to content

Commit 11a8f5d

Browse files
feat(container): move docker response dataclasses to docker_client.py
1 parent 2cca44b commit 11a8f5d

File tree

7 files changed

+1159
-131
lines changed

7 files changed

+1159
-131
lines changed

core/testcontainers/compose/compose.py

Lines changed: 2 additions & 130 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,6 +11,7 @@
1111
from types import TracebackType
1212
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast
1313

14+
from testcontainers.core.docker_client import ContainerInspectInfo, _ignore_properties
1415
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
1516
from testcontainers.core.waiting_utils import WaitStrategy
1617

@@ -20,135 +21,6 @@
2021
logger = getLogger(__name__)
2122

2223

23-
def _ignore_properties(cls: type[_IPT], dict_: Any) -> _IPT:
24-
"""omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true)
25-
26-
https://gist.github.com/alexanderankin/2a4549ac03554a31bef6eaaf2eaf7fd5"""
27-
if isinstance(dict_, cls):
28-
return dict_
29-
if not is_dataclass(cls):
30-
raise TypeError(f"Expected a dataclass type, got {cls}")
31-
class_fields = {f.name for f in fields(cls)}
32-
filtered = {k: v for k, v in dict_.items() if k in class_fields}
33-
return cast("_IPT", cls(**filtered))
34-
35-
36-
@dataclass
37-
class ContainerState:
38-
"""Container state from docker inspect."""
39-
40-
Status: Optional[str] = None
41-
Running: Optional[bool] = None
42-
Paused: Optional[bool] = None
43-
Restarting: Optional[bool] = None
44-
OOMKilled: Optional[bool] = None
45-
Dead: Optional[bool] = None
46-
Pid: Optional[int] = None
47-
ExitCode: Optional[int] = None
48-
Error: Optional[str] = None
49-
StartedAt: Optional[str] = None
50-
FinishedAt: Optional[str] = None
51-
52-
53-
@dataclass
54-
class ContainerConfig:
55-
"""Container config from docker inspect."""
56-
57-
Hostname: Optional[str] = None
58-
User: Optional[str] = None
59-
Env: Optional[list[str]] = None
60-
Cmd: Optional[list[str]] = None
61-
Image: Optional[str] = None
62-
WorkingDir: Optional[str] = None
63-
Entrypoint: Optional[list[str]] = None
64-
ExposedPorts: Optional[dict[str, Any]] = None
65-
Labels: Optional[dict[str, str]] = None
66-
67-
68-
@dataclass
69-
class Network:
70-
"""Individual network from docker inspect."""
71-
72-
IPAddress: Optional[str] = None
73-
Gateway: Optional[str] = None
74-
NetworkID: Optional[str] = None
75-
EndpointID: Optional[str] = None
76-
MacAddress: Optional[str] = None
77-
Aliases: Optional[list[str]] = None
78-
79-
80-
@dataclass
81-
class NetworkSettings:
82-
"""Network settings from docker inspect."""
83-
84-
Bridge: Optional[str] = None
85-
IPAddress: Optional[str] = None
86-
Gateway: Optional[str] = None
87-
Ports: Optional[dict[str, Any]] = None
88-
Networks: Optional[dict[str, Network]] = None
89-
90-
def get_networks(self) -> Optional[dict[str, Network]]:
91-
"""Get networks for the container."""
92-
return self.Networks
93-
94-
95-
@dataclass
96-
class ContainerInspectInfo:
97-
"""Container information from docker inspect."""
98-
99-
Id: Optional[str] = None
100-
Name: Optional[str] = None
101-
Created: Optional[str] = None
102-
Path: Optional[str] = None
103-
Args: Optional[list[str]] = None
104-
Image: Optional[str] = None
105-
State: Optional[ContainerState] = None
106-
Config: Optional[ContainerConfig] = None
107-
network_settings: Optional[NetworkSettings] = None
108-
Mounts: Optional[list[dict[str, Any]]] = None
109-
110-
@classmethod
111-
def from_dict(cls, data: dict[str, Any]) -> "ContainerInspectInfo":
112-
"""Create from docker inspect JSON."""
113-
return cls(
114-
Id=data.get("Id"),
115-
Name=data.get("Name"),
116-
Created=data.get("Created"),
117-
Path=data.get("Path"),
118-
Args=data.get("Args"),
119-
Image=data.get("Image"),
120-
State=_ignore_properties(ContainerState, data.get("State", {})) if data.get("State") else None,
121-
Config=_ignore_properties(ContainerConfig, data.get("Config", {})) if data.get("Config") else None,
122-
network_settings=cls._parse_network_settings(data.get("NetworkSettings", {}))
123-
if data.get("NetworkSettings")
124-
else None,
125-
Mounts=data.get("Mounts"),
126-
)
127-
128-
@classmethod
129-
def _parse_network_settings(cls, data: dict[str, Any]) -> Optional[NetworkSettings]:
130-
"""Parse NetworkSettings with Networks as Network objects."""
131-
if not data:
132-
return None
133-
134-
networks_data = data.get("Networks", {})
135-
networks = {}
136-
for name, net_data in networks_data.items():
137-
networks[name] = _ignore_properties(Network, net_data)
138-
139-
return NetworkSettings(
140-
Bridge=data.get("Bridge"),
141-
IPAddress=data.get("IPAddress"),
142-
Gateway=data.get("Gateway"),
143-
Ports=data.get("Ports"),
144-
Networks=networks,
145-
)
146-
147-
def get_network_settings(self) -> Optional[NetworkSettings]:
148-
"""Get network settings for the container."""
149-
return self.network_settings
150-
151-
15224
@dataclass
15325
class PublishedPortModel:
15426
"""

core/testcontainers/core/container.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from testcontainers.core.config import ConnectionMode
1616
from testcontainers.core.config import testcontainers_config as c
17-
from testcontainers.core.docker_client import DockerClient
17+
from testcontainers.core.docker_client import ContainerInspectInfo, DockerClient
1818
from testcontainers.core.exceptions import ContainerConnectException, ContainerStartException
1919
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
2020
from testcontainers.core.network import Network
@@ -96,6 +96,7 @@ def __init__(
9696

9797
self._kwargs = kwargs
9898
self._wait_strategy: Optional[WaitStrategy] = _wait_strategy
99+
self._cached_container_info: Optional[ContainerInspectInfo] = None
99100

100101
def with_env(self, key: str, value: str) -> Self:
101102
self.env[key] = value
@@ -300,6 +301,24 @@ def exec(self, command: Union[str, list[str]]) -> ExecResult:
300301
raise ContainerStartException("Container should be started before executing a command")
301302
return self._container.exec_run(command)
302303

304+
def get_container_info(self) -> Optional[ContainerInspectInfo]:
305+
"""Get container information via docker inspect (lazy loaded)."""
306+
if self._cached_container_info is not None:
307+
return self._cached_container_info
308+
309+
if not self._container:
310+
return None
311+
312+
try:
313+
raw_data = self._container.attrs
314+
self._cached_container_info = ContainerInspectInfo.from_dict(raw_data)
315+
316+
except Exception as e:
317+
logger.warning(f"Failed to get container info for {self._container.id}: {e}")
318+
self._cached_container_info = None
319+
320+
return self._cached_container_info
321+
303322
def _configure(self) -> None:
304323
# placeholder if subclasses want to define this and use the default start method
305324
pass

0 commit comments

Comments
 (0)