Skip to content

Commit f1866d7

Browse files
refactor: simplify container info parsing using __post_init__ pattern and centralize in DockerClient
1 parent 18bfdca commit f1866d7

File tree

4 files changed

+111
-269
lines changed

4 files changed

+111
-269
lines changed

core/testcontainers/compose/compose.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,10 @@
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
14+
from testcontainers.core.docker_client import ContainerInspectInfo, DockerClient, _ignore_properties
1515
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
1616
from testcontainers.core.waiting_utils import WaitStrategy
1717

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

2120
logger = getLogger(__name__)
@@ -145,15 +144,8 @@ def get_container_info(self) -> Optional[ContainerInspectInfo]:
145144
return None
146145

147146
try:
148-
inspect_command = ["docker", "inspect", self.ID]
149-
result = self._docker_compose._run_command(cmd=inspect_command)
150-
inspect_output = result.stdout.decode("utf-8").strip()
151-
152-
if inspect_output:
153-
raw_data = loads(inspect_output)[0]
154-
self._cached_container_info = ContainerInspectInfo.from_dict(raw_data)
155-
else:
156-
self._cached_container_info = None
147+
docker_client = self._docker_compose._get_docker_client()
148+
self._cached_container_info = docker_client.get_container_inspect_info(self.ID)
157149

158150
except Exception as e:
159151
logger.warning(f"Failed to get container info for {self.ID}: {e}")
@@ -229,6 +221,7 @@ class DockerCompose:
229221
docker_command_path: Optional[str] = None
230222
profiles: Optional[list[str]] = None
231223
_wait_strategies: Optional[dict[str, Any]] = field(default=None, init=False, repr=False)
224+
_docker_client: Optional[DockerClient] = field(default=None, init=False, repr=False)
232225

233226
def __post_init__(self) -> None:
234227
if isinstance(self.compose_file_name, str):
@@ -589,3 +582,9 @@ def wait_for(self, url: str) -> "DockerCompose":
589582
with urlopen(url) as response:
590583
response.read()
591584
return self
585+
586+
def _get_docker_client(self) -> DockerClient:
587+
"""Get Docker client instance."""
588+
if self._docker_client is None:
589+
self._docker_client = DockerClient()
590+
return self._docker_client

core/testcontainers/core/container.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,7 @@ def get_container_info(self) -> Optional[ContainerInspectInfo]:
310310
return None
311311

312312
try:
313-
raw_data = self._container.attrs
314-
self._cached_container_info = ContainerInspectInfo.from_dict(raw_data)
313+
self._cached_container_info = self.get_docker_client().get_container_inspect_info(self._container.id)
315314

316315
except Exception as e:
317316
logger.warning(f"Failed to get container info for {self._container.id}: {e}")

core/testcontainers/core/docker_client.py

Lines changed: 74 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,11 @@ def client_networks_create(self, name: str, param: dict[str, Any]) -> "DockerNet
266266
labels = create_labels("", param.get("labels"))
267267
return self.client.networks.create(name, **{**param, "labels": labels})
268268

269+
def get_container_inspect_info(self, container_id: str) -> "ContainerInspectInfo":
270+
"""Get container inspect information with fresh data."""
271+
container = self.client.containers.get(container_id)
272+
return ContainerInspectInfo.from_dict(container.attrs)
273+
269274

270275
def get_docker_host() -> Optional[str]:
271276
return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
@@ -542,6 +547,44 @@ class ContainerHostConfig:
542547
MaskedPaths: Optional[list[str]] = None
543548
ReadonlyPaths: Optional[list[str]] = None
544549

550+
def __post_init__(self) -> None:
551+
list_conversions = [
552+
("BlkioWeightDevice", ContainerBlkioWeightDevice),
553+
("BlkioDeviceReadBps", ContainerBlkioDeviceRate),
554+
("BlkioDeviceWriteBps", ContainerBlkioDeviceRate),
555+
("BlkioDeviceReadIOps", ContainerBlkioDeviceRate),
556+
("BlkioDeviceWriteIOps", ContainerBlkioDeviceRate),
557+
("Devices", ContainerDeviceMapping),
558+
("DeviceRequests", ContainerDeviceRequest),
559+
("Ulimits", ContainerUlimit),
560+
("Mounts", ContainerMountPoint),
561+
]
562+
563+
for field_name, target_class in list_conversions:
564+
field_value = getattr(self, field_name)
565+
if field_value is not None and isinstance(field_value, list):
566+
setattr(
567+
self,
568+
field_name,
569+
[
570+
_ignore_properties(target_class, item) if isinstance(item, dict) else item
571+
for item in field_value
572+
],
573+
)
574+
575+
if self.LogConfig is not None and isinstance(self.LogConfig, dict):
576+
self.LogConfig = _ignore_properties(ContainerLogConfig, self.LogConfig)
577+
578+
if self.RestartPolicy is not None and isinstance(self.RestartPolicy, dict):
579+
self.RestartPolicy = _ignore_properties(ContainerRestartPolicy, self.RestartPolicy)
580+
581+
if self.PortBindings is not None and isinstance(self.PortBindings, dict):
582+
for port, bindings in self.PortBindings.items():
583+
if bindings is not None and isinstance(bindings, list):
584+
self.PortBindings[port] = [
585+
_ignore_properties(ContainerPortBinding, b) if isinstance(b, dict) else b for b in bindings
586+
]
587+
545588

546589
@dataclass
547590
class ContainerGraphDriver:
@@ -669,6 +712,31 @@ class ContainerNetworkSettings:
669712
MacAddress: Optional[str] = None
670713
Networks: Optional[dict[str, ContainerNetworkEndpoint]] = None
671714

715+
def __post_init__(self) -> None:
716+
if self.Ports is not None and isinstance(self.Ports, dict):
717+
for port, bindings in self.Ports.items():
718+
if bindings is not None and isinstance(bindings, list):
719+
self.Ports[port] = [
720+
_ignore_properties(ContainerPortBinding, b) if isinstance(b, dict) else b for b in bindings
721+
]
722+
723+
if self.Networks is not None and isinstance(self.Networks, dict):
724+
for name, network_data in self.Networks.items():
725+
if isinstance(network_data, dict):
726+
self.Networks[name] = _ignore_properties(ContainerNetworkEndpoint, network_data)
727+
728+
if self.SecondaryIPAddresses is not None and isinstance(self.SecondaryIPAddresses, list):
729+
self.SecondaryIPAddresses = [
730+
_ignore_properties(ContainerAddress, addr) if isinstance(addr, dict) else addr
731+
for addr in self.SecondaryIPAddresses
732+
]
733+
734+
if self.SecondaryIPv6Addresses is not None and isinstance(self.SecondaryIPv6Addresses, list):
735+
self.SecondaryIPv6Addresses = [
736+
_ignore_properties(ContainerAddress, addr) if isinstance(addr, dict) else addr
737+
for addr in self.SecondaryIPv6Addresses
738+
]
739+
672740
def get_networks(self) -> Optional[dict[str, ContainerNetworkEndpoint]]:
673741
"""Get networks for the container."""
674742
return self.Networks
@@ -730,15 +798,17 @@ def from_dict(cls, data: dict[str, Any]) -> "ContainerInspectInfo":
730798
ProcessLabel=data.get("ProcessLabel"),
731799
AppArmorProfile=data.get("AppArmorProfile"),
732800
ExecIDs=data.get("ExecIDs"),
733-
HostConfig=cls._parse_host_config(data.get("HostConfig", {})) if data.get("HostConfig") else None,
801+
HostConfig=_ignore_properties(ContainerHostConfig, data.get("HostConfig", {}))
802+
if data.get("HostConfig")
803+
else None,
734804
GraphDriver=_ignore_properties(ContainerGraphDriver, data.get("GraphDriver", {}))
735805
if data.get("GraphDriver")
736806
else None,
737807
SizeRw=data.get("SizeRw"),
738808
SizeRootFs=data.get("SizeRootFs"),
739809
Mounts=[_ignore_properties(ContainerMount, mount) for mount in data.get("Mounts", [])],
740810
Config=_ignore_properties(ContainerConfig, data.get("Config", {})) if data.get("Config") else None,
741-
NetworkSettings=cls._parse_network_settings(data.get("NetworkSettings", {}))
811+
NetworkSettings=_ignore_properties(ContainerNetworkSettings, data.get("NetworkSettings", {}))
742812
if data.get("NetworkSettings")
743813
else None,
744814
)
@@ -799,164 +869,14 @@ def _parse_host_config(cls, data: dict[str, Any]) -> Optional[ContainerHostConfi
799869
"""Parse HostConfig with all nested objects."""
800870
if not data:
801871
return None
802-
803-
blkio_weight_devices = [
804-
_ignore_properties(ContainerBlkioWeightDevice, d) for d in (data.get("BlkioWeightDevice") or [])
805-
]
806-
blkio_read_bps = [
807-
_ignore_properties(ContainerBlkioDeviceRate, d) for d in (data.get("BlkioDeviceReadBps") or [])
808-
]
809-
blkio_write_bps = [
810-
_ignore_properties(ContainerBlkioDeviceRate, d) for d in (data.get("BlkioDeviceWriteBps") or [])
811-
]
812-
blkio_read_iops = [
813-
_ignore_properties(ContainerBlkioDeviceRate, d) for d in (data.get("BlkioDeviceReadIOps") or [])
814-
]
815-
blkio_write_iops = [
816-
_ignore_properties(ContainerBlkioDeviceRate, d) for d in (data.get("BlkioDeviceWriteIOps") or [])
817-
]
818-
devices = [_ignore_properties(ContainerDeviceMapping, d) for d in (data.get("Devices") or [])]
819-
device_requests = [_ignore_properties(ContainerDeviceRequest, d) for d in (data.get("DeviceRequests") or [])]
820-
ulimits = [_ignore_properties(ContainerUlimit, d) for d in (data.get("Ulimits") or [])]
821-
mounts = [_ignore_properties(ContainerMountPoint, d) for d in (data.get("Mounts") or [])]
822-
823-
port_bindings: dict[str, Optional[list[ContainerPortBinding]]] = {}
824-
port_bindings_data = data.get("PortBindings")
825-
if port_bindings_data is not None:
826-
for port, bindings in port_bindings_data.items():
827-
if bindings is None:
828-
port_bindings[port] = None
829-
else:
830-
port_bindings[port] = [_ignore_properties(ContainerPortBinding, b) for b in bindings]
831-
832-
return ContainerHostConfig(
833-
CpuShares=data.get("CpuShares"),
834-
Memory=data.get("Memory"),
835-
CgroupParent=data.get("CgroupParent"),
836-
BlkioWeight=data.get("BlkioWeight"),
837-
BlkioWeightDevice=blkio_weight_devices if blkio_weight_devices else None,
838-
BlkioDeviceReadBps=blkio_read_bps if blkio_read_bps else None,
839-
BlkioDeviceWriteBps=blkio_write_bps if blkio_write_bps else None,
840-
BlkioDeviceReadIOps=blkio_read_iops if blkio_read_iops else None,
841-
BlkioDeviceWriteIOps=blkio_write_iops if blkio_write_iops else None,
842-
CpuPeriod=data.get("CpuPeriod"),
843-
CpuQuota=data.get("CpuQuota"),
844-
CpuRealtimePeriod=data.get("CpuRealtimePeriod"),
845-
CpuRealtimeRuntime=data.get("CpuRealtimeRuntime"),
846-
CpusetCpus=data.get("CpusetCpus"),
847-
CpusetMems=data.get("CpusetMems"),
848-
Devices=devices if devices else None,
849-
DeviceCgroupRules=data.get("DeviceCgroupRules"),
850-
DeviceRequests=device_requests if device_requests else None,
851-
KernelMemoryTCP=data.get("KernelMemoryTCP"),
852-
MemoryReservation=data.get("MemoryReservation"),
853-
MemorySwap=data.get("MemorySwap"),
854-
MemorySwappiness=data.get("MemorySwappiness"),
855-
NanoCpus=data.get("NanoCpus"),
856-
OomKillDisable=data.get("OomKillDisable"),
857-
Init=data.get("Init"),
858-
PidsLimit=data.get("PidsLimit"),
859-
Ulimits=ulimits if ulimits else None,
860-
CpuCount=data.get("CpuCount"),
861-
CpuPercent=data.get("CpuPercent"),
862-
IOMaximumIOps=data.get("IOMaximumIOps"),
863-
IOMaximumBandwidth=data.get("IOMaximumBandwidth"),
864-
Binds=data.get("Binds"),
865-
ContainerIDFile=data.get("ContainerIDFile"),
866-
LogConfig=_ignore_properties(ContainerLogConfig, data.get("LogConfig", {}))
867-
if data.get("LogConfig")
868-
else None,
869-
NetworkMode=data.get("NetworkMode"),
870-
PortBindings=port_bindings if port_bindings else None,
871-
RestartPolicy=_ignore_properties(ContainerRestartPolicy, data.get("RestartPolicy", {}))
872-
if data.get("RestartPolicy")
873-
else None,
874-
AutoRemove=data.get("AutoRemove"),
875-
VolumeDriver=data.get("VolumeDriver"),
876-
VolumesFrom=data.get("VolumesFrom"),
877-
Mounts=mounts if mounts else None,
878-
ConsoleSize=data.get("ConsoleSize"),
879-
Annotations=data.get("Annotations"),
880-
CapAdd=data.get("CapAdd"),
881-
CapDrop=data.get("CapDrop"),
882-
CgroupnsMode=data.get("CgroupnsMode"),
883-
Dns=data.get("Dns"),
884-
DnsOptions=data.get("DnsOptions"),
885-
DnsSearch=data.get("DnsSearch"),
886-
ExtraHosts=data.get("ExtraHosts"),
887-
GroupAdd=data.get("GroupAdd"),
888-
IpcMode=data.get("IpcMode"),
889-
Cgroup=data.get("Cgroup"),
890-
Links=data.get("Links"),
891-
OomScoreAdj=data.get("OomScoreAdj"),
892-
PidMode=data.get("PidMode"),
893-
Privileged=data.get("Privileged"),
894-
PublishAllPorts=data.get("PublishAllPorts"),
895-
ReadonlyRootfs=data.get("ReadonlyRootfs"),
896-
SecurityOpt=data.get("SecurityOpt"),
897-
StorageOpt=data.get("StorageOpt"),
898-
Tmpfs=data.get("Tmpfs"),
899-
UTSMode=data.get("UTSMode"),
900-
UsernsMode=data.get("UsernsMode"),
901-
ShmSize=data.get("ShmSize"),
902-
Sysctls=data.get("Sysctls"),
903-
Runtime=data.get("Runtime"),
904-
Isolation=data.get("Isolation"),
905-
MaskedPaths=data.get("MaskedPaths"),
906-
ReadonlyPaths=data.get("ReadonlyPaths"),
907-
)
872+
return _ignore_properties(ContainerHostConfig, data)
908873

909874
@classmethod
910875
def _parse_network_settings(cls, data: dict[str, Any]) -> Optional[ContainerNetworkSettings]:
911876
"""Parse NetworkSettings with nested Networks and Ports."""
912877
if not data:
913878
return None
914-
915-
ports: dict[str, Optional[list[ContainerPortBinding]]] = {}
916-
ports_data = data.get("Ports")
917-
if ports_data is not None:
918-
for port, bindings in ports_data.items():
919-
if bindings is None:
920-
ports[port] = None
921-
else:
922-
ports[port] = [_ignore_properties(ContainerPortBinding, b) for b in bindings]
923-
924-
networks = {}
925-
networks_data = data.get("Networks")
926-
if networks_data is not None:
927-
for name, network_data in networks_data.items():
928-
networks[name] = _ignore_properties(ContainerNetworkEndpoint, network_data)
929-
930-
secondary_ipv4 = []
931-
secondary_ipv4_data = data.get("SecondaryIPAddresses")
932-
if secondary_ipv4_data is not None:
933-
secondary_ipv4 = [_ignore_properties(ContainerAddress, addr) for addr in secondary_ipv4_data]
934-
935-
secondary_ipv6 = []
936-
secondary_ipv6_data = data.get("SecondaryIPv6Addresses")
937-
if secondary_ipv6_data is not None:
938-
secondary_ipv6 = [_ignore_properties(ContainerAddress, addr) for addr in secondary_ipv6_data]
939-
940-
return ContainerNetworkSettings(
941-
Bridge=data.get("Bridge"),
942-
SandboxID=data.get("SandboxID"),
943-
HairpinMode=data.get("HairpinMode"),
944-
LinkLocalIPv6Address=data.get("LinkLocalIPv6Address"),
945-
LinkLocalIPv6PrefixLen=data.get("LinkLocalIPv6PrefixLen"),
946-
Ports=ports if ports else None,
947-
SandboxKey=data.get("SandboxKey"),
948-
SecondaryIPAddresses=secondary_ipv4 if secondary_ipv4 else None,
949-
SecondaryIPv6Addresses=secondary_ipv6 if secondary_ipv6 else None,
950-
EndpointID=data.get("EndpointID"),
951-
Gateway=data.get("Gateway"),
952-
GlobalIPv6Address=data.get("GlobalIPv6Address"),
953-
GlobalIPv6PrefixLen=data.get("GlobalIPv6PrefixLen"),
954-
IPAddress=data.get("IPAddress"),
955-
IPPrefixLen=data.get("IPPrefixLen"),
956-
IPv6Gateway=data.get("IPv6Gateway"),
957-
MacAddress=data.get("MacAddress"),
958-
Networks=networks if networks else None,
959-
)
879+
return _ignore_properties(ContainerNetworkSettings, data)
960880

961881
def get_network_settings(self) -> Optional[ContainerNetworkSettings]:
962882
"""Get network settings for the container."""

0 commit comments

Comments
 (0)