4747INSTANCE_TYPE = "ml.m5.large"
4848REPACK_SCRIPT = "_repack_model.py"
4949REPACK_SCRIPT_LAUNCHER = "_repack_script_launcher.sh"
50+ # Suffix appended to an ``s3://`` source_dir object name to derive the SDK-owned
51+ # staging key. The caller's original object is never overwritten (issue #6481).
52+ STAGED_SOURCE_DIR_SUFFIX = "-sagemaker-repack.tar.gz"
5053LAUNCH_REPACK_SCRIPT_CMD = """
5154#!/bin/bash
5255
@@ -179,8 +182,11 @@ def __init__(
179182 self ._entry_point_basename = os .path .basename (self ._entry_point )
180183 self ._source_dir = source_dir
181184 self ._requirements = requirements
185+ # The SDK-owned copy of the source that the repack helper files are written
186+ # into. ``None`` until the first ``_prepare_for_repacking()`` call.
187+ self ._staged_source_dir = None
182188
183- # Prepare source directory with repack scripts
189+ # Stage an SDK-owned copy of the source with the repack scripts in it
184190 self ._prepare_for_repacking ()
185191
186192 # Handle requirements.txt like ModelTrainer
@@ -194,7 +200,7 @@ def __init__(
194200 from sagemaker .core .training .configs import SourceCode , Compute , Networking
195201
196202 source_code = SourceCode (
197- source_dir = self ._source_dir ,
203+ source_dir = self ._staged_source_dir ,
198204 entry_script = REPACK_SCRIPT_LAUNCHER ,
199205 requirements = requirements_file ,
200206 )
@@ -251,11 +257,31 @@ def __init__(
251257 )
252258
253259 def _prepare_for_repacking (self ):
254- """Prepares the source for the model trainer."""
260+ """Stages an SDK-owned copy of the source for the model trainer.
261+
262+ The caller-provided ``source_dir`` is never modified: the repack helper
263+ files are written into a copy that the SDK owns -- a temporary directory
264+ for a local ``source_dir``, a new S3 object for an ``s3://`` one. Staging
265+ runs at most once, so repeated ``arguments`` access neither re-stages nor
266+ re-uploads (see issue #6481).
267+ """
268+ if self ._staged_source_dir is not None :
269+ return
270+
271+ if self ._source_dir is not None and self ._source_dir .lower ().startswith ("s3://" ):
272+ self ._staged_source_dir = self ._stage_s3_source_dir ()
273+ return
274+
255275 if self ._source_dir is None :
276+ # _establish_source_dir already builds a directory that the SDK owns,
277+ # so the helper files can go straight into it.
256278 self ._establish_source_dir ()
279+ self ._staged_source_dir = self ._source_dir
280+ else :
281+ self ._staged_source_dir = tempfile .mkdtemp ()
282+ shutil .copytree (self ._source_dir , self ._staged_source_dir , dirs_exist_ok = True )
257283
258- self ._inject_repack_script_and_launcher ()
284+ self ._inject_repack_script_and_launcher (self . _staged_source_dir )
259285
260286 def _establish_source_dir (self ):
261287 """If the source_dir is None, creates it for the repacking job.
@@ -272,69 +298,93 @@ def _establish_source_dir(self):
272298 shutil .copy2 (self ._entry_point , os .path .join (self ._source_dir , self ._entry_point_basename ))
273299 self ._entry_point = self ._entry_point_basename
274300
275- def _inject_repack_script_and_launcher (self ):
301+ def _inject_repack_script_and_launcher (self , target_dir ):
276302 """Injects the _repack_model.py script and _repack_script_launcher.sh
277303
278- into S3 or local source directory.
304+ into a local directory that the SDK owns .
279305
280306 Note: The bash file is needed because if not supplied, the SKLearn
281307 training job will auto install all dependencies listed in requirements.txt.
282308 However, this auto install behavior is not expected in _RepackModelStep,
283309 since it should just simply repack the model along with other supplied files,
284310 e.g. the requirements.txt.
285311
286- If the source_dir is an S3 path:
312+ It performs the following:
313+ 1) copies the _repack_model.py script into ``target_dir``
314+ 2) creates the _repack_script_launcher.sh in ``target_dir``
315+
316+ Args:
317+ target_dir (str): A local directory owned by the SDK. This is never
318+ the caller-provided ``source_dir`` (see issue #6481).
319+ """
320+ fname = os .path .join (os .path .dirname (__file__ ), REPACK_SCRIPT )
321+ shutil .copy2 (fname , os .path .join (target_dir , REPACK_SCRIPT ))
322+ # newline="\n" forces LF endings so the bash launcher stays runnable in the
323+ # Linux repack container even when written from a Windows host (see issue #3762).
324+ with open (
325+ os .path .join (target_dir , REPACK_SCRIPT_LAUNCHER ), "w" , newline = "\n "
326+ ) as launcher_file :
327+ launcher_file .write (LAUNCH_REPACK_SCRIPT_CMD )
328+
329+ def _stage_s3_source_dir (self ):
330+ """Stages an ``s3://`` source archive into a new, SDK-owned S3 object.
331+
332+ It performs the following:
287333 1) downloads the source_dir tar.gz
288334 2) extracts it
289335 3) copies the _repack_model.py script into the extracted directory
290336 4) creates the _repack_script_launcher.sh in the extracted dir
291337 5) rezips the directory
292- 6) overwrites the S3 source_dir with the new tar.gz
338+ 6) uploads the new tar.gz under a new key, alongside the original
293339
294- If the source_dir is a local path:
295- 1) copies the _repack_model.py script into the source dir
296- 2) creates the _repack_script_launcher.sh in the source dir
340+ The caller's object is read but never overwritten (see issue #6481). The
341+ staging key is derived from the original one, so it stays in the caller's
342+ bucket and prefix and inherits the same access and encryption policies.
343+
344+ Returns:
345+ str: The S3 URI of the staged source archive.
297346 """
298- fname = os .path .join (os .path .dirname (__file__ ), REPACK_SCRIPT )
299- if self ._source_dir .lower ().startswith ("s3://" ):
300- with tempfile .TemporaryDirectory () as tmp :
301- targz_contents_dir = os .path .join (tmp , "extracted" )
347+ staged_uri = self ._staged_s3_source_dir_uri ()
348+ with tempfile .TemporaryDirectory () as tmp :
349+ targz_contents_dir = os .path .join (tmp , "extracted" )
302350
303- old_targz_path = os .path .join (tmp , "old.tar.gz" )
304- download_file_from_url (self ._source_dir , old_targz_path , self .sagemaker_session )
351+ old_targz_path = os .path .join (tmp , "old.tar.gz" )
352+ download_file_from_url (self ._source_dir , old_targz_path , self .sagemaker_session )
305353
306- with tarfile .open (name = old_targz_path , mode = "r:gz" ) as t :
307- custom_extractall_tarfile (t , targz_contents_dir )
354+ with tarfile .open (name = old_targz_path , mode = "r:gz" ) as t :
355+ custom_extractall_tarfile (t , targz_contents_dir )
308356
309- shutil .copy2 (fname , os .path .join (targz_contents_dir , REPACK_SCRIPT ))
310- # newline="\n" forces LF endings so the bash launcher stays runnable in the
311- # Linux repack container even when written from a Windows host (see issue #3762).
312- with open (
313- os .path .join (targz_contents_dir , REPACK_SCRIPT_LAUNCHER ), "w" , newline = "\n "
314- ) as launcher_file :
315- launcher_file .write (LAUNCH_REPACK_SCRIPT_CMD )
357+ self ._inject_repack_script_and_launcher (targz_contents_dir )
316358
317- new_targz_path = os .path .join (tmp , "new.tar.gz" )
318- with tarfile .open (new_targz_path , mode = "w:gz" ) as t :
319- t .add (targz_contents_dir , arcname = os .path .sep )
359+ new_targz_path = os .path .join (tmp , "new.tar.gz" )
360+ with tarfile .open (new_targz_path , mode = "w:gz" ) as t :
361+ t .add (targz_contents_dir , arcname = os .path .sep )
320362
321- _save_model (self ._source_dir , new_targz_path , self .sagemaker_session , kms_key = None )
322- else :
323- shutil .copy2 (fname , os .path .join (self ._source_dir , REPACK_SCRIPT ))
324- # newline="\n" forces LF endings so the bash launcher stays runnable in the
325- # Linux repack container even when written from a Windows host (see issue #3762).
326- with open (
327- os .path .join (self ._source_dir , REPACK_SCRIPT_LAUNCHER ), "w" , newline = "\n "
328- ) as launcher_file :
329- launcher_file .write (LAUNCH_REPACK_SCRIPT_CMD )
363+ _save_model (staged_uri , new_targz_path , self .sagemaker_session , kms_key = None )
364+
365+ return staged_uri
366+
367+ def _staged_s3_source_dir_uri (self ):
368+ """Derives the staging S3 URI for an ``s3://`` source_dir.
369+
370+ Returns:
371+ str: The original URI with its object name suffixed, keeping the
372+ caller's bucket and prefix.
373+ """
374+ prefix , _ , object_name = self ._source_dir .rpartition ("/" )
375+ for extension in (".tar.gz" , ".tgz" ):
376+ if object_name .lower ().endswith (extension ):
377+ object_name = object_name [: - len (extension )]
378+ break
379+ return "/" .join ([prefix , object_name + STAGED_SOURCE_DIR_SUFFIX ])
330380
331381 @property
332382 def arguments (self ) -> RequestType :
333383 """The arguments dict that are used to call `create_training_job`.
334384
335- This first prepares the source bundle for repackinglby placing artifacts
336- in locations which the training container will make available to the
337- repacking script and then gets the arguments for the training job .
385+ The source bundle is staged once, by the constructor, so repeated access
386+ here neither re-stages the source nor touches the caller's ``source_dir``
387+ (see issue #6481) .
338388 """
339389 self ._prepare_for_repacking ()
340390 return super (_RepackModelStep , self ).arguments
0 commit comments