1+ import logging
12import time
3+ from http import HTTPStatus
4+
25import docker
36import requests
4- from typing import Optional
57from docker import errors
68
79from eventsourcingdb .client import Client
810
11+ HOST_PORT_KEY = 'HostPort'
12+ HTTP_OK = HTTPStatus .OK
13+
914
1015class Container :
1116 def __init__ (
1217 self ,
1318 image_name : str = "thenativeweb/eventsourcingdb" ,
14- image_tag : str = "0.113.2 " ,
19+ image_tag : str = "0.120.4 " ,
1520 api_token : str = "secret" ,
1621 internal_port : int = 3000
1722 ):
@@ -21,7 +26,7 @@ def __init__(
2126 self ._api_token = api_token
2227 self ._container = None
2328 self ._docker_client = docker .from_env ()
24- self ._mapped_port : Optional [ int ] = None
29+ self ._mapped_port : int | None = None
2530 self ._host = "localhost"
2631
2732 def with_image_tag (self , tag : str ) -> "Container" :
@@ -39,7 +44,7 @@ def with_port(self, port: int) -> "Container":
3944 def start (self ) -> "Container" :
4045 if self ._container is not None :
4146 return self
42-
47+
4348 try :
4449 self ._docker_client .images .pull (self ._image_name , self ._image_tag )
4550 except errors .APIError as e :
@@ -49,17 +54,27 @@ def start(self) -> "Container":
4954 self ._create_container ()
5055 self ._fetch_mapped_port ()
5156 self ._wait_for_http ("/api/v1/ping" , timeout = 20 )
52-
57+
5358 return self
5459
5560 def _cleanup_existing_containers (self ) -> None :
5661 try :
57- for container in self ._docker_client .containers .list (
58- filters = {"ancestor" : f"{ self ._image_name } :{ self ._image_tag } " }):
62+ containers = self ._docker_client .containers .list (
63+ filters = {"ancestor" : f"{ self ._image_name } :{ self ._image_tag } " })
64+ except errors .APIError as e :
65+ logging .warning ("Warning: Error listing existing containers: %s" , e )
66+ return
67+
68+ for container in containers :
69+ try :
5970 container .stop ()
71+ except errors .APIError as e :
72+ logging .warning ("Warning: Error stopping container: %s" , e )
73+
74+ try :
6075 container .remove ()
61- except Exception as e :
62- print ( f "Warning: Error stopping existing containers: { e } " )
76+ except errors . APIError as e :
77+ logging . warning ( "Warning: Error removing container: %s" , e )
6378
6479 def _create_container (self ) -> None :
6580 port_bindings = {f"{ self ._internal_port } /tcp" : None }
@@ -73,64 +88,109 @@ def _create_container(self) -> None:
7388 "--http-enabled" ,
7489 "--https-enabled=false" ,
7590 ],
76- ports = port_bindings , # type: ignore
91+ ports = port_bindings , # type: ignore
7792 detach = True ,
78- ) # type: ignore
93+ ) # type: ignore
7994
8095 def _fetch_mapped_port (self ) -> None :
8196 if self ._container is None :
8297 raise RuntimeError ("Container failed to start" )
83-
98+
8499 max_retries , retry_delay = 5 , 1
85-
100+
86101 for attempt in range (max_retries ):
87- try :
88- container_info = self ._docker_client .api .inspect_container (
89- self ._container .id )
90- port_mappings = container_info ["NetworkSettings" ]["Ports" ].get (f"{ self ._internal_port } /tcp" )
91-
92- if port_mappings and "HostPort" in port_mappings [0 ]:
93- self ._mapped_port = int (port_mappings [0 ]["HostPort" ])
94- return
95- except (KeyError , IndexError , AttributeError ):
96- pass
97-
102+ if (port := self ._try_get_port_from_container ()) is not None :
103+ self ._mapped_port = port
104+ return
105+
98106 if attempt < max_retries - 1 :
99107 time .sleep (retry_delay )
100-
101- # Failed to get port, clean up
108+
102109 self ._stop_and_remove_container ()
103110 raise RuntimeError ("Failed to determine mapped port" )
104111
112+ def _try_get_port_from_container (self ) -> int | None :
113+ if not self ._container :
114+ return None
115+
116+ if not (container_info := self ._get_container_info ()):
117+ return None
118+
119+ return self ._extract_port_from_container_info (container_info )
120+
121+ def _get_container_info (self ):
122+ if self ._container is None :
123+ return None
124+ return self ._docker_client .api .inspect_container (self ._container .id )
125+
126+ def _extract_port_from_container_info (self , container_info ):
127+ port = None
128+ valid_mapping = True
129+ port_mappings = None
130+
131+ try :
132+ port_mappings = container_info ["NetworkSettings" ]["Ports" ].get (
133+ f"{ self ._internal_port } /tcp" )
134+ except KeyError :
135+ valid_mapping = False
136+
137+ if valid_mapping and port_mappings and isinstance (port_mappings , list ) and port_mappings :
138+ first_mapping = port_mappings [0 ]
139+
140+ if HOST_PORT_KEY in first_mapping :
141+ port_value = first_mapping [HOST_PORT_KEY ]
142+ port = int (port_value )
143+
144+ return port
145+
105146 def _wait_for_http (self , path : str , timeout : int ) -> None :
106147 base_url = self .get_base_url ()
107148 url = f"{ base_url } { path } "
108149 start_time = time .time ()
109-
110- while time .time () - start_time < timeout :
150+
151+ max_attempts = int (timeout * 2 )
152+ for _ in range (max_attempts ):
153+ if time .time () - start_time >= timeout :
154+ break
155+
156+ response = None
157+ status_code = None
111158 try :
112159 response = requests .get (url , timeout = 2 )
113- if response .status_code == 200 :
114- return
115160 except requests .RequestException :
116161 pass
162+
163+ if response is not None :
164+ status_code = response .status_code
165+
166+ if response is not None and status_code == HTTP_OK :
167+ return
168+
117169 time .sleep (0.5 )
118-
170+
119171 self ._stop_and_remove_container ()
120172 raise TimeoutError (f"Service failed to become ready within { timeout } seconds" )
121173
122174 def _stop_and_remove_container (self ) -> None :
123175 if self ._container is None :
124176 return
125-
126- try :
177+
178+ try :
127179 self ._container .stop ()
180+ except errors .NotFound as e :
181+ print (f"Warning: Container not found while stopping: { e } " )
182+ except errors .APIError as e :
183+ print (f"Warning: API error while stopping container: { e } " )
184+
185+ try :
128186 self ._container .remove ()
129- except Exception as e :
130- print (f"Warning: Error stopping container: { e } " )
131- finally :
132- self ._container = None
133- self ._mapped_port = None
187+ except errors .NotFound as e :
188+ print (f"Warning: Container not found while removing: { e } " )
189+ except errors .APIError as e :
190+ print (f"Warning: API error while removing container: { e } " )
191+
192+ self ._container = None
193+ self ._mapped_port = None
134194
135195 def get_host (self ) -> str :
136196 if self ._container is None :
0 commit comments