Skip to content

Commit 44a124e

Browse files
committed
fix: improve path handling in sync client pull operation
1 parent e4e2e74 commit 44a124e

File tree

3 files changed

+49
-22
lines changed

3 files changed

+49
-22
lines changed

src/humanloop/cli/__main__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def cli(): # Does nothing because used as a group for other subcommands (pull,
154154
"-p",
155155
help="Path in the Humanloop workspace to pull from (file or directory). You can pull an entire directory (e.g. 'my/directory') "
156156
"or a specific file (e.g. 'my/directory/my_prompt.prompt'). When pulling a directory, all files within that directory and its subdirectories will be included. "
157+
"Paths should not contain leading or trailing slashes. "
157158
"If not specified, pulls from the root of the remote workspace.",
158159
default=None,
159160
)

src/humanloop/client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,11 @@ def pull(self, path: Optional[str] = None, environment: Optional[str] = None) ->
431431
432432
If you specify `local_files_directory="data/humanloop"`, files will be saved in ./data/humanloop/ instead.
433433
434+
Important note about paths:
435+
- Paths should not contain leading or trailing slashes
436+
- When specifying a file path, include the file extension (`.prompt` or `.agent`)
437+
- When specifying a directory path, do not include a trailing slash
438+
434439
:param path: Optional path to either a specific file (e.g. "path/to/file.prompt") or a directory (e.g. "path/to/directory").
435440
If not provided, all Prompt and Agent files will be pulled.
436441
:param environment: The environment to pull the files from.

src/humanloop/sync/sync_client.py

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)