@@ -103,7 +103,7 @@ def _get_file_content_implementation(self, path: str, file_type: SerializableFil
103103 This is the actual implementation that gets wrapped by lru_cache.
104104
105105 Args:
106- path: The normalized path to the file (without extension )
106+ path: The API path to the file (e.g. `path/to/file` )
107107 file_type: The type of file to get the content of (SerializableFileType)
108108
109109 Returns:
@@ -154,10 +154,10 @@ def clear_cache(self) -> None:
154154 """Clear the LRU cache."""
155155 self .get_file_content .cache_clear () # type: ignore [attr-defined]
156156
157- def _normalize_path (self , path : str ) -> str :
157+ def _normalize_path (self , path : str , strip_extension : bool = False ) -> str :
158158 """Normalize the path by:
159159 1. Converting to a Path object to handle platform-specific separators
160- 2. Removing any file extensions
160+ 2. Removing any file extensions if strip_extension is True
161161 3. Converting to a string with forward slashes and no leading/trailing slashes
162162 """
163163 # Convert to Path object to handle platform-specific separators
@@ -172,7 +172,10 @@ def _normalize_path(self, path: str) -> str:
172172 )
173173
174174 # Remove extension, convert to string with forward slashes, and remove leading/trailing slashes
175- normalized = str (path_obj .with_suffix ("" ))
175+ if strip_extension :
176+ normalized = str (path_obj .with_suffix ("" ))
177+ else :
178+ normalized = str (path_obj )
176179 # Replace all backslashes and normalize multiple forward slashes
177180 return "/" .join (part for part in normalized .replace ("\\ " , "/" ).split ("/" ) if part )
178181
@@ -319,12 +322,17 @@ def _pull_directory(
319322 def pull (self , path : Optional [str ] = None , environment : Optional [str ] = None ) -> Tuple [List [str ], List [str ]]:
320323 """Pull files from Humanloop to local filesystem.
321324
322- If the path ends with .prompt or .agent, pulls that specific file.
325+ If the path ends with ` .prompt` or ` .agent` , pulls that specific file.
323326 Otherwise, pulls all files under the specified path.
324327 If no path is provided, pulls all files from the root.
325328
326329 Args:
327- path: The path to pull from (either a specific file or directory)
330+ path: The path to pull from. Can be:
331+ - A specific file with extension (e.g. "path/to/file.prompt")
332+ - A directory without extension (e.g. "path/to/directory")
333+ - None to pull all files from root
334+
335+ Paths should not contain leading or trailing slashes
328336 environment: The environment to pull from
329337
330338 Returns:
@@ -336,40 +344,53 @@ def pull(self, path: Optional[str] = None, environment: Optional[str] = None) ->
336344 HumanloopRuntimeError: If there's an error communicating with the API
337345 """
338346 start_time = time .time ()
339- normalized_path = self ._normalize_path (path ) if path else None
340347
341- logger .info (
342- f"Starting pull operation: path={ normalized_path or '(root)' } , environment={ environment or '(default)' } "
343- )
348+ if path is None :
349+ api_path = None
350+ is_file_path = False
351+ else :
352+ path = path .strip ()
353+ # Check if path has leading/trailing slashes
354+ if path != path .strip ("/" ):
355+ raise HumanloopRuntimeError (
356+ f"Invalid path: { path } . Path should not contain leading/trailing slashes. "
357+ f'Valid examples: "path/to/file.prompt" or "path/to/directory"'
358+ )
359+
360+ # Check if it's a file path (has extension)
361+ is_file_path = self .is_file (path )
362+
363+ # For API communication, we need path without extension
364+ api_path = self ._normalize_path (path , strip_extension = True )
365+
366+ logger .info (f"Starting pull: path={ api_path or '(root)' } , environment={ environment or '(default)' } " )
344367
345368 try :
346- if (
347- normalized_path is None or path is None
348- ): # path being None means normalized_path is None, but we check both for improved type safety
349- # Pull all files from the root
369+ if api_path is None :
370+ # Pull all from root
350371 logger .debug ("Pulling all files from root" )
351372 successful_files , failed_files = self ._pull_directory (
352373 path = None ,
353374 environment = environment ,
354375 )
355376 else :
356- if self . is_file ( path . strip ()) :
357- logger .debug (f"Pulling file: { normalized_path } " )
358- if self ._pull_file (path = normalized_path , environment = environment ):
359- successful_files = [path ]
377+ if is_file_path :
378+ logger .debug (f"Pulling file: { api_path } " )
379+ if self ._pull_file (api_path , environment ):
380+ successful_files = [api_path ]
360381 failed_files = []
361382 else :
362383 successful_files = []
363- failed_files = [path ]
384+ failed_files = [api_path ]
364385 else :
365- logger .debug (f"Pulling directory: { normalized_path } " )
366- successful_files , failed_files = self ._pull_directory (normalized_path , environment )
386+ logger .debug (f"Pulling directory: { api_path } " )
387+ successful_files , failed_files = self ._pull_directory (api_path , environment )
367388
368389 # Clear the cache at the end of each pull operation
369390 self .clear_cache ()
370391
371392 duration_ms = int ((time .time () - start_time ) * 1000 )
372- logger .info (f"Pull completed in { duration_ms } ms: { len (successful_files )} files succeeded " )
393+ logger .info (f"Pull completed in { duration_ms } ms: { len (successful_files )} files pulled " )
373394
374395 return successful_files , failed_files
375396 except Exception as e :
0 commit comments