@@ -185,6 +185,72 @@ def clear_image_cache() -> None:
185185 logger .info ("Cleared cached image from %s" , CACHE_FILE )
186186
187187
188+ def _build_fresh_base_image (
189+ app , dockerfile_path : str | None
190+ ) -> tuple [modal .Image , str ]:
191+ """Build a fresh base image (no caching)."""
192+ if dockerfile_path is None :
193+ logger .info ("Building default base image..." )
194+ base_img = modal .Image .debian_slim (python_version = "3.11" ).pip_install ("pytest" )
195+ else :
196+ logger .info ("Building base image from %s with context_dir=." , dockerfile_path )
197+ base_img = modal .Image .from_dockerfile (dockerfile_path , context_dir = "." )
198+
199+ base_img .build (app )
200+ # Materialize to get base image_id for caching
201+ temp_sandbox = modal .Sandbox .create (app = app , image = base_img , timeout = 10 )
202+ temp_sandbox .terminate ()
203+ base_img_id = base_img .object_id
204+ # Cache the base image
205+ write_cached_image_id (base_img_id )
206+ logger .info ("Cached base image_id to %s" , CACHE_FILE )
207+ return base_img , base_img_id
208+
209+
210+ def _build_final_image (
211+ app ,
212+ base_img : modal .Image ,
213+ base_img_id : str ,
214+ include_cwd : bool ,
215+ copy_dirs : tuple [str , ...],
216+ ignore_patterns : list [str ],
217+ ) -> str :
218+ """Build final image with cwd/copy-dirs on top of base. Returns image_id."""
219+ final_img = base_img
220+
221+ if include_cwd :
222+ logger .info ("Adding current directory as /app..." )
223+ final_img = final_img .add_local_dir (
224+ "." , "/app" , copy = True , ignore = ignore_patterns
225+ )
226+
227+ # Add user-specified directories
228+ for copy_spec in copy_dirs :
229+ if ":" not in copy_spec :
230+ logger .warning (
231+ "Invalid copy-dir format '%s', expected 'local:remote'" ,
232+ copy_spec ,
233+ )
234+ continue
235+ local_path , remote_path = copy_spec .split (":" , 1 )
236+ if not os .path .isdir (local_path ):
237+ logger .warning ("Local directory '%s' not found, skipping" , local_path )
238+ continue
239+ logger .info ("Adding %s -> %s to image" , local_path , remote_path )
240+ final_img = final_img .add_local_dir (
241+ local_path , remote_path , copy = True , ignore = ignore_patterns
242+ )
243+
244+ # Build and materialize the final image if we added anything
245+ if final_img is not base_img :
246+ final_img .build (app )
247+ temp_sandbox = modal .Sandbox .create (app = app , image = final_img , timeout = 10 )
248+ temp_sandbox .terminate ()
249+ return final_img .object_id
250+ else :
251+ return base_img_id
252+
253+
188254@cli .command ("prepare" )
189255@click .argument ("dockerfile_path" , required = False , default = None )
190256@click .option ("--cached" , is_flag = True , help = "Use cached BASE image if available" )
@@ -232,67 +298,6 @@ def prepare(
232298 sys .exit (1 )
233299 app_name = "offload-dockerfile-sandbox"
234300
235- def build_fresh_base_image (app ) -> tuple [modal .Image , str ]:
236- """Build a fresh base image (no caching)."""
237- if dockerfile_path is None :
238- logger .info ("Building default base image..." )
239- base_img = modal .Image .debian_slim (python_version = "3.11" ).pip_install (
240- "pytest"
241- )
242- else :
243- logger .info (
244- "Building base image from %s with context_dir=." , dockerfile_path
245- )
246- base_img = modal .Image .from_dockerfile (dockerfile_path , context_dir = "." )
247-
248- base_img .build (app )
249- # Materialize to get base image_id for caching
250- temp_sandbox = modal .Sandbox .create (app = app , image = base_img , timeout = 10 )
251- temp_sandbox .terminate ()
252- base_img_id = base_img .object_id
253- # Cache the base image
254- write_cached_image_id (base_img_id )
255- logger .info ("Cached base image_id to %s" , CACHE_FILE )
256- return base_img , base_img_id
257-
258- def build_final_image (
259- app , base_img : modal .Image , base_img_id : str
260- ) -> str :
261- """Build final image with cwd/copy-dirs on top of base. Returns image_id."""
262- final_img = base_img
263-
264- if include_cwd :
265- logger .info ("Adding current directory as /app..." )
266- final_img = final_img .add_local_dir (
267- "." , "/app" , copy = True , ignore = ignore_patterns
268- )
269-
270- # Add user-specified directories
271- for copy_spec in copy_dirs :
272- if ":" not in copy_spec :
273- logger .warning (
274- "Invalid copy-dir format '%s', expected 'local:remote'" ,
275- copy_spec ,
276- )
277- continue
278- local_path , remote_path = copy_spec .split (":" , 1 )
279- if not os .path .isdir (local_path ):
280- logger .warning ("Local directory '%s' not found, skipping" , local_path )
281- continue
282- logger .info ("Adding %s -> %s to image" , local_path , remote_path )
283- final_img = final_img .add_local_dir (
284- local_path , remote_path , copy = True , ignore = ignore_patterns
285- )
286-
287- # Build and materialize the final image if we added anything
288- if final_img is not base_img :
289- final_img .build (app )
290- temp_sandbox = modal .Sandbox .create (app = app , image = final_img , timeout = 10 )
291- temp_sandbox .terminate ()
292- return final_img .object_id
293- else :
294- return base_img_id
295-
296301 with modal .enable_output ():
297302 app = modal .App .lookup (app_name , create_if_missing = True )
298303
@@ -309,19 +314,23 @@ def build_final_image(
309314
310315 # Step 2: Build fresh base image if no cache
311316 if base_image is None :
312- base_image , base_image_id = build_fresh_base_image (app )
317+ base_image , base_image_id = _build_fresh_base_image (app , dockerfile_path )
313318
314319 # Step 3: Build final image, catching cache invalidation errors
315320 try :
316- image_id = build_final_image (app , base_image , base_image_id )
317- except Exception as e :
321+ image_id = _build_final_image (
322+ app , base_image , base_image_id , include_cwd , copy_dirs , ignore_patterns
323+ )
324+ except Exception as e : # noqa: BLE001 - rebuild on any failure
318325 # Cached image no longer exists on Modal - rebuild from scratch
319326 logger .warning (
320327 "Failed to use cached image (%s), rebuilding from scratch..." , e
321328 )
322329 clear_image_cache ()
323- base_image , base_image_id = build_fresh_base_image (app )
324- image_id = build_final_image (app , base_image , base_image_id )
330+ base_image , base_image_id = _build_fresh_base_image (app , dockerfile_path )
331+ image_id = _build_final_image (
332+ app , base_image , base_image_id , include_cwd , copy_dirs , ignore_patterns
333+ )
325334
326335 sys .stdout .write ("%s\n " % image_id )
327336
@@ -484,7 +493,7 @@ def create_from_image(
484493 logger .debug ("[%.2fs] Loading image %s..." , time .time () - t0 , image_id )
485494 try :
486495 image = modal .Image .from_id (image_id )
487- except Exception as e :
496+ except Exception as e : # noqa: BLE001
488497 logger .error ("Failed to load image %s: %s" , image_id , e )
489498 logger .error (
490499 "The image may have been garbage collected. "
@@ -507,7 +516,7 @@ def create_from_image(
507516 timeout = 3600 ,
508517 secrets = secrets ,
509518 )
510- except Exception as e :
519+ except Exception as e : # noqa: BLE001
511520 logger .error ("Failed to create sandbox with image %s: %s" , image_id , e )
512521 logger .error (
513522 "The image may have been garbage collected. "
0 commit comments