Skip to content

Commit 816eaff

Browse files
committed
add path filter for pulling
1 parent 6c01255 commit 816eaff

File tree

2 files changed

+103
-15
lines changed

2 files changed

+103
-15
lines changed

src/humanloop/client.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,12 +367,17 @@ def pull(self,
367367
"""Pull prompt and agent files from Humanloop to local filesystem.
368368
369369
This method will:
370-
1. Fetch all prompt and agent files from your Humanloop workspace
370+
1. Fetch prompt and agent files from your Humanloop workspace
371371
2. Save them to the local filesystem using the client's files_directory (set during initialization)
372372
3. Maintain the same directory structure as in Humanloop
373373
4. Add appropriate file extensions (.prompt or .agent)
374374
375-
By default, the operation will overwrite existing files with the latest version from Humanlooop
375+
The path parameter can be used in two ways:
376+
- If it points to a specific file (e.g. "path/to/file.prompt" or "path/to/file.agent"), only that file will be pulled
377+
- If it points to a directory (e.g. "path/to/directory"), all prompt and agent files in that directory will be pulled
378+
- If no path is provided, all prompt and agent files will be pulled
379+
380+
The operation will overwrite existing files with the latest version from Humanloop
376381
but will not delete local files that don't exist in the remote workspace.
377382
378383
Currently only supports syncing prompt and agent files. Other file types will be skipped.
@@ -389,7 +394,8 @@ def pull(self,
389394
```
390395
391396
:param environment: The environment to pull the files from.
392-
:param path: The path to the files to pull on the Humanloop workspace. Can be a directory or a specific file.
397+
:param path: Optional path to either a specific file (e.g. "path/to/file.prompt") or a directory (e.g. "path/to/directory").
398+
If not provided, all prompt and agent files will be pulled.
393399
:return: List of successfully processed file paths.
394400
"""
395401
return self._sync_client.pull(

src/humanloop/sync/sync_client.py

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,56 @@ def __init__(
4040
self.base_dir = Path(base_dir)
4141
self.max_workers = max_workers or multiprocessing.cpu_count() * 2
4242

43+
def _normalize_path(self, path: str) -> str:
44+
"""Normalize the path by:
45+
1. Removing any file extensions (.prompt, .agent)
46+
2. Converting backslashes to forward slashes
47+
3. Removing leading and trailing slashes
48+
4. Removing leading and trailing whitespace
49+
5. Normalizing multiple consecutive slashes into a single forward slash
50+
51+
Args:
52+
path: The path to normalize
53+
54+
Returns:
55+
The normalized path
56+
"""
57+
# Remove any file extensions
58+
path = path.rsplit('.', 1)[0] if '.' in path else path
59+
60+
# Convert backslashes to forward slashes and normalize multiple slashes
61+
path = path.replace('\\', '/')
62+
63+
# Remove leading/trailing whitespace and slashes
64+
path = path.strip().strip('/')
65+
66+
# Normalize multiple consecutive slashes into a single forward slash
67+
while '//' in path:
68+
path = path.replace('//', '/')
69+
70+
return path
71+
72+
def is_file(self, path: str) -> bool:
73+
"""Check if the path is a file by checking for .prompt or .agent extension.
74+
75+
Args:
76+
path: The path to check
77+
78+
Returns:
79+
True if the path ends with .prompt or .agent, False otherwise
80+
"""
81+
return path.endswith('.prompt') or path.endswith('.agent')
82+
4383
def _save_serialized_file(self, serialized_content: str, file_path: str, file_type: FileType) -> None:
4484
"""Save serialized file to local filesystem.
4585
4686
Args:
4787
serialized_content: The content to save
4888
file_path: The path where to save the file
4989
file_type: The type of file (prompt or agent)
90+
91+
Raises:
92+
Exception: If there is an error saving the file
5093
"""
5194
try:
5295
# Create full path including base_dir prefix
@@ -65,26 +108,44 @@ def _save_serialized_file(self, serialized_content: str, file_path: str, file_ty
65108
logger.error(f"Failed to sync {file_type} {file_path}: {str(e)}")
66109
raise
67110

68-
def pull(self,
111+
def _pull_file(self, path: str, environment: str | None = None) -> None:
112+
"""Pull a specific file from Humanloop to local filesystem.
113+
114+
Args:
115+
path: The path of the file without the extension (e.g. "path/to/file")
116+
environment: The environment to pull the file from
117+
118+
Raises:
119+
ValueError: If the file type is not supported
120+
Exception: If there is an error pulling the file
121+
"""
122+
file = self.client.files.retrieve_by_path(
123+
path,
124+
environment=environment,
125+
include_content=True
126+
)
127+
128+
if file.type not in ["prompt", "agent"]:
129+
raise ValueError(f"Unsupported file type: {file.type}")
130+
131+
self._save_serialized_file(file.content, file.path, file.type)
132+
133+
def _pull_directory(self,
134+
path: str | None = None,
69135
environment: str | None = None,
70-
directory: str | None = None,
71-
path: str | None = None,
72136
) -> List[str]:
73137
"""Sync prompt and agent files from Humanloop to local filesystem.
74138
75-
If `path` is provided, only the file at that path will be pulled.
76-
If `directory` is provided, all files in that directory will be pulled (if both `path` and `directory` are provided, `path` will take precedence).
139+
If `path` is provided, only the files under that path will be pulled.
77140
If `environment` is provided, the files will be pulled from that environment.
78141
79142
Args:
80-
environment: The environment to pull the files from.
81-
directory: The directory to pull the files from.
82-
path: The path of a specific file to pull from.
143+
path: The path of the directory to pull from (e.g. "path/to/directory")
144+
environment: The environment to pull the files from
83145
84146
Returns:
85147
List of successfully processed file paths
86148
"""
87-
88149
successful_files = []
89150
failed_files = []
90151
page = 1
@@ -95,7 +156,8 @@ def pull(self,
95156
type=["prompt", "agent"],
96157
page=page,
97158
include_content=True,
98-
environment=environment
159+
environment=environment,
160+
directory=path
99161
)
100162

101163
if len(response.records) == 0:
@@ -109,7 +171,7 @@ def pull(self,
109171
continue
110172

111173
if not file.path.startswith(path):
112-
# Filter by path
174+
# Filter by path
113175
continue
114176

115177
# Skip if no content
@@ -135,4 +197,24 @@ def pull(self,
135197
if failed_files:
136198
logger.error(f"Failed to sync {len(failed_files)} files")
137199

138-
return successful_files
200+
return successful_files
201+
202+
def pull(self, path: str, environment: str | None = None) -> List[str]:
203+
"""Pull files from Humanloop to local filesystem.
204+
205+
If the path ends with .prompt or .agent, pulls that specific file.
206+
Otherwise, pulls all files under the specified directory path.
207+
208+
Args:
209+
path: The path to pull from (either a specific file or directory)
210+
environment: The environment to pull from
211+
212+
Returns:
213+
List of successfully processed file paths
214+
"""
215+
normalized_path = self._normalize_path(path)
216+
if self.is_file(path):
217+
self._pull_file(normalized_path, environment)
218+
return [path]
219+
else:
220+
return self._pull_directory(normalized_path, environment)

0 commit comments

Comments
 (0)