33# /// script
44# requires-python = ">=3.8"
55# dependencies = [
6- # "requests",
7- # "urllib3",
86# "watchdog",
97# ]
108# ///
2321import sys
2422import time
2523
26- from subprocess import CalledProcessError
2724from watchdog .events import PatternMatchingEventHandler
2825from watchdog .observers import Observer
2926
3027from argparse import RawTextHelpFormatter
3128
32- # from dataclasses import dataclass
33- # from datetime import datetime
3429from pathlib import Path
3530from typing import Any , Callable , Dict , List
3631
7671###########
7772
7873
79- def run (args : List [str ]) -> str :
74+ def run (
75+ cmd : List [str ], check : bool = True , * args , ** kwargs
76+ ) -> subprocess .CompletedProcess :
8077 """
8178 Executes a shell command, checks for success, and returns its stdout.
8279
8380 Args:
8481 args: A list of strings representing the command and its arguments.
8582
86- Returns:
87- The standard output of the command as a string.
88-
8983 Raises:
9084 subprocess.CalledProcessError: If the command returns a non-zero exit code.
9185 """
92- log .debug (f"Running command: { ' ' .join (args )} " )
93- result = subprocess .run (args , check = True , text = True , capture_output = True )
94- return result .stdout
95-
96-
97- def is_docker_container_running (container_name : str ) -> bool :
98- """Checks if a Docker container is currently running."""
99- try :
100- output = run (["docker" , "inspect" , "-f" , "{{.State.Running}}" , container_name ]).strip ()
101- return output .lower () == "true"
102- except CalledProcessError :
103- return False
86+ log .debug (f"Running command: { ' ' .join (cmd )} " )
87+ res = subprocess .run (cmd , * args , check = check , text = True , ** kwargs )
88+ return res
10489
10590
10691def doc (dic : Dict [str , Callable [..., Any ]]) -> str :
@@ -111,226 +96,141 @@ def doc(dic: Dict[str, Callable[..., Any]]) -> str:
11196 return doc_string
11297
11398
114- def cleanup_containers (full_cleanup : bool ) -> None :
115- """Remove Docker containers and network conditionally.
99+ def cleanup_lila () -> None :
100+ """Remove Lila Docker container and network unconditionally."""
101+ log .info (f"Cleaning up Lila container..." )
102+ run (["docker" , "rm" , "--force" , BDIT_LILA ])
116103
117- :param full_cleanup: If True, remove BDIT_LILA and BDIT_NETWORK. Always attempts to remove BDIT_APP.
118- """
119- log .info (f"Cleaning up containers (full_cleanup={ full_cleanup } )..." )
120-
121- try :
122- run (["docker" , "rm" , "--force" , BDIT_APP ])
123- log .info (f"Removed container: { BDIT_APP } " )
124- except CalledProcessError as e :
125- if "No such container" in e .stderr :
126- log .debug (f"Container { BDIT_APP } not found, skipping removal." )
127- else :
128- log .warning (f"Failed to remove { BDIT_APP } : { e .stderr } " )
129-
130- if full_cleanup :
131- try :
132- run (["docker" , "rm" , "--force" , BDIT_LILA ])
133- log .info (f"Removed container: { BDIT_LILA } " )
134- except CalledProcessError as e :
135- if "No such container" in e .stderr :
136- log .debug (f"Container { BDIT_LILA } not found, skipping removal." )
137- else :
138- log .warning (f"Failed to remove { BDIT_LILA } : { e .stderr } " )
139104
140- try :
141- run (["docker" , "network" , "rm" , BDIT_NETWORK ])
142- log .info (f"Removed network: { BDIT_NETWORK } " )
143- except CalledProcessError as e :
144- if "no such network" in e .stderr :
145- log .debug (f"Network { BDIT_NETWORK } not found, skipping removal." )
146- else :
147- log .warning (f"Failed to remove { BDIT_NETWORK } : { e .stderr } " )
148- else :
149- log .info (f"Leaving { BDIT_LILA } and { BDIT_NETWORK } intact." )
105+ def cleanup_containers () -> None :
106+ """Remove Docker containers and network unconditionally."""
107+ log .info (f"Cleaning up containers..." )
108+ run (["docker" , "rm" , "--force" , BDIT_APP ])
109+ cleanup_lila ()
110+ run (["docker" , "network" , "rm" , "--force" , BDIT_NETWORK ])
150111 log .info ("Cleanup complete." )
151112
152113
114+ def run_lila ():
115+ run (
116+ [
117+ "docker" ,
118+ "run" ,
119+ "--name" ,
120+ BDIT_LILA ,
121+ "--network" ,
122+ BDIT_NETWORK ,
123+ "-d" ,
124+ BDIT_IMAGE ,
125+ ]
126+ )
127+
128+
153129class ChangeHandler (PatternMatchingEventHandler ):
154- def __init__ (self , project_root : Path ) :
130+ def __init__ (self , on_change_callback : Callable [[], None ]) -> None :
155131 super ().__init__ (
156132 patterns = ["*.py" ],
157133 ignore_patterns = [],
158134 ignore_directories = True ,
159135 case_sensitive = False ,
160136 )
161- self ._should_run_test = False
162- self ._project_root = project_root
137+ self ._already_running = False
138+ self ._on_change_callback = on_change_callback
163139
164140 def on_any_event (self , event ):
165- # Filter for changes in berserk or integration/tests directories
166- try :
167- relative_path = Path (event .src_path ).relative_to (self ._project_root )
168- if relative_path .parts [0 ] == "berserk" or (relative_path .parts [0 ] == "integration" and relative_path .parts [1 ] == "tests" ):
169- log .debug (f"Detected change in { event .src_path } : { event .event_type } " )
170- self .trigger_test ()
171- except ValueError :
172- # Event outside project_root, ignore
173- pass
174-
175- def trigger_test (self ):
176- self ._should_run_test = True
177-
178- def should_run_test (self ):
179- return self ._should_run_test
180-
181- def reset_trigger (self ):
182- self ._should_run_test = False
141+ # feels like race-conditions are waiting to happen here...
142+ if not self ._already_running :
143+ self ._already_running = True
144+ self ._on_change_callback ()
145+ self ._already_running = False
183146
184147
185- def integration_test (_watch : bool ) -> None :
148+ def integration_test (watch : bool ) -> None :
186149 """Run the Berserk Docker Image Test (BDIT)."""
187150 log .info ("Running integration tests" )
188151
189152 project_root = SCRIPT_DIR .parent
190153
191- # Initial cleanup: Always remove BDIT_APP. For BDIT_LILA/BDIT_NETWORK, depends on watch mode.
192- cleanup_containers (full_cleanup = not _watch )
193-
194- # Ensure BDIT_NETWORK exists
195- try :
196- run (["docker" , "network" , "create" , BDIT_NETWORK ])
197- log .info (f"Created network: { BDIT_NETWORK } " )
198- except CalledProcessError as e :
199- if "network with name bdit_lila-network already exists" in e .stderr :
200- log .debug (f"Network { BDIT_NETWORK } already exists." )
201- else :
202- log .error (f"Failed to create network { BDIT_NETWORK } : { e .stderr } " )
203- raise
204-
205- # Ensure BDIT_LILA is running.
206- if not is_docker_container_running (BDIT_LILA ):
207- log .info (f"Starting Lila container: { BDIT_LILA } with image { BDIT_IMAGE } " )
208- try :
209- run (
210- [
211- "docker" ,
212- "run" ,
213- "--name" ,
214- BDIT_LILA ,
215- "--network" ,
216- BDIT_NETWORK ,
217- "-d" ,
218- BDIT_IMAGE ,
219- ]
220- )
221- except CalledProcessError as e :
222- if "conflict: container name" in e .stderr :
223- log .warning (
224- f"Container { BDIT_LILA } already exists but was not reported as running. Attempting to restart..."
225- )
226- run (["docker" , "rm" , "--force" , BDIT_LILA ])
227- run ( # Retry after force removal
228- [
229- "docker" ,
230- "run" ,
231- "--name" ,
232- BDIT_LILA ,
233- "--network" ,
234- BDIT_NETWORK ,
235- "-d" ,
236- BDIT_IMAGE ,
237- ]
238- )
239- else :
240- log .error (f"Failed to start Lila container { BDIT_LILA } : { e .stderr } " )
241- raise
154+ cleanup_containers ()
155+
156+ run (["docker" , "network" , "create" , BDIT_NETWORK ])
157+ log .info (f"Created network: { BDIT_NETWORK } " )
158+ run_lila ()
159+ log .info (f"Started Lila container: { BDIT_LILA } " )
242160
243161 # Build the application image (always rebuild to ensure latest changes)
244162 dockerfile_path = SCRIPT_DIR / "Dockerfile"
245- uv_cache_dir = run (["uv" , "cache" , "dir" ]) .strip ()
163+ uv_cache_dir = run (["uv" , "cache" , "dir" ], capture_output = True ). stdout .strip ()
246164 log .info (
247165 f"Building Docker image: { BDIT_APP_IMAGE } from { project_root } using { dockerfile_path } "
248166 )
249- run (
250- [
251- "docker" ,
252- "build" ,
253- "-f" ,
254- str (dockerfile_path ),
255- str (project_root ),
256- "--build-arg" ,
257- f"UV_CACHE_DIR={ uv_cache_dir } " ,
258- "-t" ,
259- BDIT_APP_IMAGE ,
260- ]
261- )
262167
263- def run_app_test ():
264- """Helper to run the BDIT_APP container."""
168+ def build_and_run_test_image ():
169+ run (
170+ [
171+ "docker" ,
172+ "build" ,
173+ "-f" ,
174+ str (dockerfile_path ),
175+ str (project_root ),
176+ "--build-arg" ,
177+ f"UV_CACHE_DIR={ uv_cache_dir } " ,
178+ "-t" ,
179+ BDIT_APP_IMAGE ,
180+ ]
181+ )
265182 log .info (f"Running app container: { BDIT_APP } with image { BDIT_APP_IMAGE } " )
266- try :
267- run (
268- [
269- "docker" ,
270- "run" ,
271- "--rm" , # Always remove after execution
272- "--name" ,
273- BDIT_APP ,
274- "--network" ,
275- BDIT_NETWORK ,
276- BDIT_APP_IMAGE ,
277- ]
278- )
279- log .info ("App container finished successfully." )
280- except CalledProcessError as e :
281- log .error (f"App container { BDIT_APP } failed with exit code { e .returncode } :" )
282- log .error (f"stdout: { e .stdout } " )
283- log .error (f"stderr: { e .stderr } " )
284- if _watch :
285- log .info ("Continuing in watch mode..." )
286- else :
287- raise
288-
289- if _watch :
290- log .info ("Entering watch mode. Monitoring files in 'berserk/' and 'integration/tests/'. Press Ctrl+C to stop." )
291-
292- event_handler = ChangeHandler (project_root )
183+ run (
184+ [
185+ "docker" ,
186+ "run" ,
187+ "--rm" , # Always remove after execution
188+ "--name" ,
189+ BDIT_APP ,
190+ "--network" ,
191+ BDIT_NETWORK ,
192+ BDIT_APP_IMAGE ,
193+ ],
194+ )
195+ log .info ("App container finished successfully." )
196+
197+ # Initial test
198+ build_and_run_test_image ()
199+
200+ if watch :
201+ # the tests need a fresh lila everytime, due to some actions not being idempotent
202+ # like kid mode or bot upgrade
203+ cleanup_lila ()
204+ run_lila ()
205+ log .info (
206+ "Entering watch mode. Monitoring files in 'berserk/' and 'integration/tests/'. Press Ctrl+C to stop."
207+ )
208+
209+ def safe_build_and_run_test_image ():
210+ try :
211+ build_and_run_test_image ()
212+ except subprocess .CalledProcessError as e :
213+ log .error (f"Error during build or test run: { e } " )
214+ cleanup_lila ()
215+ run_lila ()
216+
217+ event_handler = ChangeHandler (safe_build_and_run_test_image )
293218 observer = Observer ()
294219 observer .schedule (event_handler , project_root / "berserk" , recursive = True )
295- observer .schedule (event_handler , project_root / "integration" / "tests" , recursive = True )
220+ observer .schedule (
221+ event_handler , project_root / "integration" / "tests" , recursive = True
222+ )
296223 observer .start ()
297224
298225 try :
299- # Run initial test
300- run_app_test ()
301226 while True :
302- if event_handler .should_run_test ():
303- event_handler .reset_trigger ()
304- log .info ("File change detected, rebuilding and re-running app container..." )
305- # Rebuild the app image on change
306- run (
307- [
308- "docker" ,
309- "build" ,
310- "-f" ,
311- str (dockerfile_path ),
312- str (project_root ),
313- "--build-arg" ,
314- f"UV_CACHE_DIR={ uv_cache_dir } " ,
315- "-t" ,
316- BDIT_APP_IMAGE ,
317- ]
318- )
319- run_app_test ()
320227 time .sleep (1 )
321228 except KeyboardInterrupt :
322229 log .info ("Watch mode stopped by user." )
323230 finally :
324231 observer .stop ()
325232 observer .join ()
326- log .info (f"Watch mode exited. To clean up { BDIT_LILA } and { BDIT_NETWORK } , run without --watch." )
327-
328- else :
329- # Non-watch mode: run once, then perform full cleanup
330- run_app_test ()
331- cleanup_containers (full_cleanup = True )
332-
333- log .info ("✅ Done" )
233+ cleanup_containers ()
334234
335235
336236def main () -> None :
0 commit comments