Skip to content

Commit 02c0803

Browse files
committed
basic sync functionality
1 parent ab3562b commit 02c0803

File tree

7 files changed

+167
-8
lines changed

7 files changed

+167
-8
lines changed

.fernignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mypy.ini
1313
README.md
1414
src/humanloop/decorators
1515
src/humanloop/otel
16+
src/humanloop/sync
1617

1718
## Tests
1819

src/humanloop/agents/raw_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1886,7 +1886,7 @@ def serialize(
18861886
)
18871887
try:
18881888
if 200 <= _response.status_code < 300:
1889-
return HttpResponse(response=_response, data=None)
1889+
return HttpResponse(response=_response, data=_response.text)
18901890
if _response.status_code == 422:
18911891
raise UnprocessableEntityError(
18921892
typing.cast(

src/humanloop/client.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from humanloop.otel.processor import HumanloopSpanProcessor
2525
from humanloop.prompt_utils import populate_template
2626
from humanloop.prompts.client import PromptsClient
27+
from humanloop.sync import sync
2728

2829

2930
class ExtendedEvalsClient(EvaluationsClient):
@@ -82,8 +83,9 @@ class Humanloop(BaseHumanloop):
8283
"""
8384
See docstring of :class:`BaseHumanloop`.
8485
85-
This class extends the base client with custom evaluation utilities
86-
and decorators for declaring Files in code.
86+
This class extends the base client with custom evaluation utilities,
87+
decorators for declaring Files in code, and utilities for syncing
88+
files between Humanloop and local filesystem.
8789
"""
8890

8991
def __init__(
@@ -348,8 +350,31 @@ def agent():
348350
attributes=attributes,
349351
)
350352

351-
def sync(self):
352-
return "Hello world"
353+
def sync(self) -> List[str]:
354+
"""Sync prompt and agent files from Humanloop to local filesystem.
355+
356+
This method will:
357+
1. Fetch all prompt and agent files from your Humanloop workspace
358+
2. Save them to the local filesystem in a 'humanloop/' directory
359+
3. Maintain the same directory structure as in Humanloop
360+
4. Add appropriate file extensions (.prompt or .agent)
361+
362+
Currently only supports syncing prompt and agent files. Other file types will be skipped.
363+
364+
The files will be saved with the following structure:
365+
```
366+
humanloop/
367+
├── prompts/
368+
│ ├── my_prompt.prompt
369+
│ └── nested/
370+
│ └── another_prompt.prompt
371+
└── agents/
372+
└── my_agent.agent
373+
```
374+
375+
:return: List of successfully processed file paths
376+
"""
377+
return sync(self)
353378

354379

355380
class AsyncHumanloop(AsyncBaseHumanloop):

src/humanloop/prompts/raw_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1793,7 +1793,7 @@ def serialize(
17931793
)
17941794
try:
17951795
if 200 <= _response.status_code < 300:
1796-
return HttpResponse(response=_response, data=None)
1796+
return HttpResponse(response=_response, data=_response.text)
17971797
if _response.status_code == 422:
17981798
raise UnprocessableEntityError(
17991799
typing.cast(

src/humanloop/sync/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from humanloop.sync.sync_utils import sync
2+
3+
__all__ = ["sync"]

src/humanloop/sync/sync_utils.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import os
2+
import logging
3+
from pathlib import Path
4+
import concurrent.futures
5+
from typing import List, TYPE_CHECKING, Union
6+
7+
from humanloop.types import FileType, PromptResponse, AgentResponse
8+
from humanloop.core.api_error import ApiError
9+
10+
if TYPE_CHECKING:
11+
from humanloop.base_client import BaseHumanloop
12+
13+
# Set up logging
14+
logger = logging.getLogger(__name__)
15+
logger.setLevel(logging.INFO)
16+
console_handler = logging.StreamHandler()
17+
formatter = logging.Formatter("%(message)s")
18+
console_handler.setFormatter(formatter)
19+
if not logger.hasHandlers():
20+
logger.addHandler(console_handler)
21+
22+
def _save_serialized_file(serialized_content: str, file_path: str, file_type: FileType) -> None:
23+
"""Save serialized file to local filesystem.
24+
25+
:param serialized_content: The content to save
26+
:param file_path: The path where to save the file
27+
:param file_type: The type of file (prompt or agent)
28+
"""
29+
try:
30+
# Create full path including humanloop/ prefix
31+
full_path = Path("humanloop") / file_path
32+
# Create directory if it doesn't exist
33+
full_path.parent.mkdir(parents=True, exist_ok=True)
34+
35+
# Add file type extension
36+
new_path = full_path.parent / f"{full_path.stem}.{file_type}"
37+
38+
# Write content to file
39+
with open(new_path, "w") as f:
40+
f.write(serialized_content)
41+
logger.info(f"Syncing {file_type} {file_path}")
42+
except Exception as e:
43+
logger.error(f"Failed to sync {file_type} {file_path}: {str(e)}")
44+
raise
45+
46+
def _process_file(client: "BaseHumanloop", file: Union[PromptResponse, AgentResponse]) -> None:
47+
"""Process a single file by serializing and saving it.
48+
49+
Currently only supports prompt and agent files. Other file types will be skipped.
50+
51+
:param client: Humanloop client instance
52+
:param file: The file to process (must be a PromptResponse or AgentResponse)
53+
"""
54+
try:
55+
# Serialize the file based on its type
56+
try:
57+
if file.type == "prompt":
58+
serialized = client.prompts.serialize(id=file.id)
59+
elif file.type == "agent":
60+
serialized = client.agents.serialize(id=file.id)
61+
else:
62+
logger.warning(f"Skipping unsupported file type: {file.type}")
63+
return
64+
except ApiError as e:
65+
# The SDK returns the YAML content in the error body when it can't parse as JSON
66+
if e.status_code == 200:
67+
serialized = e.body
68+
else:
69+
raise
70+
except Exception as e:
71+
logger.error(f"Failed to serialize {file.type} {file.id}: {str(e)}")
72+
raise
73+
74+
# Save to local filesystem
75+
_save_serialized_file(serialized, file.path, file.type)
76+
77+
except Exception as e:
78+
logger.error(f"Error processing file {file.path}: {str(e)}")
79+
raise
80+
81+
def sync(client: "BaseHumanloop") -> List[str]:
82+
"""Sync prompt and agent files from Humanloop to local filesystem.
83+
84+
:param client: Humanloop client instance
85+
:return: List of successfully processed file paths
86+
"""
87+
successful_files = []
88+
failed_files = []
89+
90+
# Create a thread pool for processing files
91+
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
92+
futures = []
93+
page = 1
94+
95+
while True:
96+
try:
97+
response = client.files.list_files(
98+
type=["prompt", "agent"],
99+
page=page
100+
)
101+
102+
if len(response.records) == 0:
103+
break
104+
105+
# Submit each file for processing
106+
for file in response.records:
107+
future = executor.submit(_process_file, client, file)
108+
futures.append((file.path, future))
109+
110+
page += 1
111+
except Exception as e:
112+
logger.error(f"Failed to fetch page {page}: {str(e)}")
113+
break
114+
115+
# Wait for all tasks to complete
116+
for file_path, future in futures:
117+
try:
118+
future.result()
119+
successful_files.append(file_path)
120+
except Exception as e:
121+
failed_files.append(file_path)
122+
logger.error(f"Task failed for {file_path}: {str(e)}")
123+
124+
# Log summary
125+
if successful_files:
126+
logger.info(f"\nSynced {len(successful_files)} files")
127+
if failed_files:
128+
logger.error(f"Failed to sync {len(failed_files)} files")
129+
130+
return successful_files

tests/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ def directory_cleanup(directory_id: str, humanloop_client: Humanloop):
217217
client = humanloop_client.evaluators # type: ignore [assignment]
218218
elif file.type == "flow":
219219
client = humanloop_client.flows # type: ignore [assignment]
220-
else:
221-
raise NotImplementedError(f"Unknown HL file type {file.type}")
220+
elif file.type == "agent":
221+
client = humanloop_client.agents # type: ignore [assignment]
222222
client.delete(file_id)
223223

224224
for subdirectory in response.subdirectories:

0 commit comments

Comments
 (0)