Skip to content

Commit a259335

Browse files
committed
Clean up in response to PR feedback
1 parent 1f5055d commit a259335

File tree

3 files changed

+60
-66
lines changed

3 files changed

+60
-66
lines changed

src/humanloop/cli/__main__.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
from typing import Optional, Callable
44
from functools import wraps
5-
from dotenv import load_dotenv, find_dotenv
5+
from dotenv import load_dotenv
66
import os
77
import sys
88
from humanloop import Humanloop
@@ -26,10 +26,9 @@
2626

2727

2828
def load_api_key(env_file: Optional[str] = None) -> str:
29-
"""Load API key from provided value, .env file, or environment variable.
29+
"""Load API key from .env file or environment variable.
3030
3131
Args:
32-
api_key: Optional API key provided directly
3332
env_file: Optional path to .env file
3433
3534
Returns:
@@ -38,18 +37,17 @@ def load_api_key(env_file: Optional[str] = None) -> str:
3837
Raises:
3938
click.ClickException: If no API key is found
4039
"""
41-
# Try loading from .env file
40+
# Try specific .env file if provided, otherwise default to .env in current directory
4241
if env_file:
43-
load_dotenv(env_file)
42+
if not load_dotenv(env_file): # load_dotenv returns False if file not found/invalid
43+
raise click.ClickException(
44+
click.style(
45+
f"Failed to load environment file: {env_file} (file not found or invalid format)",
46+
fg=ERROR_COLOR,
47+
)
48+
)
4449
else:
45-
# Try to find .env file in current directory or parent directories
46-
env_path = find_dotenv()
47-
if env_path:
48-
load_dotenv(env_path)
49-
elif os.path.exists(".env"):
50-
load_dotenv(".env")
51-
else:
52-
load_dotenv()
50+
load_dotenv() # Attempt to load from default .env in current directory
5351

5452
# Get API key from environment
5553
api_key = os.getenv("HUMANLOOP_API_KEY")
@@ -151,8 +149,9 @@ def cli(): # Does nothing because used as a group for other subcommands (pull,
151149
@click.option(
152150
"--path",
153151
"-p",
154-
help="Path in the Humanloop workspace to pull from (file or directory). You can pull an entire directory (e.g. 'my/directory/') "
155-
"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.",
152+
help="Path in the Humanloop workspace to pull from (file or directory). You can pull an entire directory (e.g. 'my/directory') "
153+
"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. "
154+
"If not specified, pulls from the root of the workspace.",
156155
default=None,
157156
)
158157
@click.option(
@@ -190,7 +189,7 @@ def pull(
190189
\b
191190
This command will:
192191
1. Fetch Prompt and Agent files from your Humanloop workspace
193-
2. Save them to your local filesystem (default: humanloop/)
192+
2. Save them to your local filesystem (default directory: humanloop/)
194193
3. Maintain the same directory structure as in Humanloop
195194
4. Add appropriate file extensions (.prompt or .agent)
196195

src/humanloop/overload.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,20 @@
2222
from humanloop.types.create_prompt_log_response import CreatePromptLogResponse
2323
from humanloop.types.create_tool_log_response import CreateToolLogResponse
2424
from humanloop.types.prompt_call_response import PromptCallResponse
25+
from humanloop.types.agent_call_response import AgentCallResponse
2526

2627
logger = logging.getLogger("humanloop.sdk")
2728

28-
ResponseType = Union[
29+
LogResponseType = Union[
2930
CreatePromptLogResponse,
3031
CreateToolLogResponse,
3132
CreateFlowLogResponse,
3233
CreateEvaluatorLogResponse,
34+
]
35+
36+
CallResponseType = Union[
3337
PromptCallResponse,
38+
AgentCallResponse,
3439
]
3540

3641

@@ -128,7 +133,7 @@ def _handle_evaluation_context(kwargs: Dict[str, Any]) -> tuple[Dict[str, Any],
128133
return kwargs, None
129134

130135

131-
def _overload_log(self: Any, sync_client: Optional[SyncClient], use_local_files: bool, **kwargs) -> ResponseType:
136+
def _overload_log(self: Any, sync_client: Optional[SyncClient], use_local_files: bool, **kwargs) -> LogResponseType:
132137
try:
133138
# Special handling for flows - prevent direct log usage
134139
if type(self) is FlowsClient and get_trace_id() is not None:
@@ -162,7 +167,7 @@ def _overload_log(self: Any, sync_client: Optional[SyncClient], use_local_files:
162167
raise HumanloopRuntimeError from e
163168

164169

165-
def _overload_call(self: Any, sync_client: Optional[SyncClient], use_local_files: bool, **kwargs) -> PromptCallResponse:
170+
def _overload_call(self: Any, sync_client: Optional[SyncClient], use_local_files: bool, **kwargs) -> CallResponseType:
166171
try:
167172
kwargs = _handle_tracing_context(kwargs, self)
168173
kwargs = _handle_local_files(kwargs, self, sync_client, use_local_files)
@@ -186,7 +191,7 @@ def overload_client(
186191
client._log = client.log # type: ignore [attr-defined]
187192

188193
# Create a closure to capture sync_client and use_local_files
189-
def log_wrapper(self: Any, **kwargs) -> ResponseType:
194+
def log_wrapper(self: Any, **kwargs) -> LogResponseType:
190195
return _overload_log(self, sync_client, use_local_files, **kwargs)
191196

192197
client.log = types.MethodType(log_wrapper, client)
@@ -200,7 +205,7 @@ def log_wrapper(self: Any, **kwargs) -> ResponseType:
200205
client._call = client.call # type: ignore [attr-defined]
201206

202207
# Create a closure to capture sync_client and use_local_files
203-
def call_wrapper(self: Any, **kwargs) -> PromptCallResponse:
208+
def call_wrapper(self: Any, **kwargs) -> CallResponseType:
204209
return _overload_call(self, sync_client, use_local_files, **kwargs)
205210

206211
client.call = types.MethodType(call_wrapper, client)

src/humanloop/sync/sync_client.py

Lines changed: 35 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ def format_api_error(error: Exception) -> str:
3333
try:
3434
# Extract the body part and parse as JSON
3535
body_str = error_msg.split("body: ")[1]
36-
# Convert Python dict string to valid JSON by replacing single quotes with double quotes
37-
body_str = body_str.replace("'", '"')
36+
# Convert Python dict string to valid JSON by:
37+
# 1. Escaping double quotes
38+
# 2. Replacing single quotes with double quotes
39+
body_str = body_str.replace('"', '\\"').replace("'", '"')
3840
body = json.loads(body_str)
3941

4042
# Get the detail from the body
@@ -52,7 +54,7 @@ def format_api_error(error: Exception) -> str:
5254
return error_msg
5355

5456

55-
_SERIALIZABLE_FILE_TYPES_TYPE = typing.Literal["prompt", "agent"]
57+
SerializableFileType = typing.Literal["prompt", "agent"]
5658

5759

5860
class SyncClient:
@@ -68,7 +70,7 @@ class SyncClient:
6870
"""
6971

7072
# File types that can be serialized to/from the filesystem
71-
SERIALIZABLE_FILE_TYPES = ["prompt", "agent"]
73+
SERIALIZABLE_FILE_TYPES = frozenset(typing.get_args(SerializableFileType))
7274

7375
def __init__(
7476
self,
@@ -92,19 +94,18 @@ def __init__(
9294
logger.setLevel(log_level)
9395

9496
# Create a new cached version of get_file_content with the specified cache size
95-
# @TODO: @ale, maybe move the cache to the client?
9697
self.get_file_content = lru_cache(maxsize=cache_size)( # type: ignore [assignment]
9798
self._get_file_content_implementation,
9899
)
99100

100-
def _get_file_content_implementation(self, path: str, file_type: FileType) -> str:
101+
def _get_file_content_implementation(self, path: str, file_type: SerializableFileType) -> str:
101102
"""Implementation of get_file_content without the cache decorator.
102103
103104
This is the actual implementation that gets wrapped by lru_cache.
104105
105106
Args:
106107
path: The normalized path to the file (without extension)
107-
file_type: The type of file (Prompt or Agent)
108+
file_type: The type of file to get the content of (SerializableFileType)
108109
109110
Returns:
110111
The raw file content
@@ -132,7 +133,7 @@ def _get_file_content_implementation(self, path: str, file_type: FileType) -> st
132133
except Exception as e:
133134
raise HumanloopRuntimeError(f"Error reading local file {local_path}: {str(e)}")
134135

135-
def get_file_content(self, path: str, file_type: FileType) -> str:
136+
def get_file_content(self, path: str, file_type: SerializableFileType) -> str:
136137
"""Get the raw file content of a file from cache or filesystem.
137138
138139
This method uses an LRU cache to store file contents. When the cache is full,
@@ -152,7 +153,6 @@ def get_file_content(self, path: str, file_type: FileType) -> str:
152153

153154
def clear_cache(self) -> None:
154155
"""Clear the LRU cache."""
155-
# @TODO: @ale, why not put the cache on the client? This is a bit of a hack.
156156
self.get_file_content.cache_clear() # type: ignore [attr-defined]
157157

158158
def _normalize_path(self, path: str) -> str:
@@ -170,14 +170,14 @@ def _normalize_path(self, path: str) -> str:
170170
return "/".join(part for part in normalized.replace("\\", "/").split("/") if part)
171171

172172
def is_file(self, path: str) -> bool:
173-
"""Check if the path is a file by checking for .prompt or .agent extension."""
174-
return path.endswith(".prompt") or path.endswith(".agent")
173+
"""Check if the path is a file by checking for .{file_type} extension for serializable file types."""
174+
return path.endswith(tuple(f".{file_type}" for file_type in self.SERIALIZABLE_FILE_TYPES))
175175

176176
def _save_serialized_file(
177177
self,
178178
serialized_content: str,
179179
file_path: str,
180-
file_type: typing.Literal["prompt", "agent"],
180+
file_type: SerializableFileType,
181181
) -> None:
182182
"""Save serialized file to local filesystem."""
183183
try:
@@ -192,53 +192,39 @@ def _save_serialized_file(
192192
# Write raw file content to file
193193
with open(new_path, "w") as f:
194194
f.write(serialized_content)
195-
196-
# Clear the cache when a file is saved
197-
self.clear_cache()
198195
except Exception as e:
199-
logger.error(f"Failed to sync {file_type} {file_path}: {str(e)}")
196+
logger.error(f"Failed to write {file_type} {file_path} to disk: {str(e)}")
200197
raise
201198

202-
def _pull_file(
203-
self,
204-
path: str | None = None,
205-
environment: str | None = None,
206-
) -> bool:
199+
def _pull_file(self, path: str | None = None, environment: str | None = None) -> bool:
207200
"""Pull a specific file from Humanloop to local filesystem.
208201
209202
Returns:
210203
True if the file was successfully pulled, False otherwise
211-
212-
Raises:
213-
HumanloopRuntimeError: If there's an error communicating with the API
214204
"""
215205
try:
216206
file = self.client.files.retrieve_by_path(
217207
path=path,
218208
environment=environment,
219209
include_raw_file_content=True,
220210
)
221-
except Exception as e:
222-
logger.error(f"Failed to pull file {path}: {format_api_error(e)}")
223-
return False
211+
212+
if file.type not in self.SERIALIZABLE_FILE_TYPES:
213+
logger.error(f"Unsupported file type: {file.type}")
214+
return False
224215

225-
if file.type not in self.SERIALIZABLE_FILE_TYPES:
226-
raise ValueError(f"Unsupported file type: {file.type}")
216+
if not file.raw_file_content: # type: ignore [union-attr]
217+
logger.error(f"No content found for {file.type} {path}")
218+
return False
227219

228-
file_type: _SERIALIZABLE_FILE_TYPES_TYPE = typing.cast(
229-
_SERIALIZABLE_FILE_TYPES_TYPE,
230-
file.type,
231-
)
232-
233-
try:
234220
self._save_serialized_file(
235-
serialized_content=file.raw_file_content, # type: ignore [union-attr, arg-type]
221+
serialized_content=file.raw_file_content, # type: ignore [union-attr]
236222
file_path=file.path,
237-
file_type=file_type,
223+
file_type=typing.cast(SerializableFileType, file.type),
238224
)
239225
return True
240226
except Exception as e:
241-
logger.error(f"Failed to save file {path}: {str(e)}")
227+
logger.error(f"Failed to pull file {path}: {str(e)}")
242228
return False
243229

244230
def _pull_directory(
@@ -251,7 +237,8 @@ def _pull_directory(
251237
Returns:
252238
Tuple of two lists:
253239
- First list contains paths of successfully synced files
254-
- Second list contains paths of files that failed to sync
240+
- Second list contains paths of files that failed to sync.
241+
Failures can occur due to missing content in the response or errors during local file writing.
255242
256243
Raises:
257244
HumanloopRuntimeError: If there's an error communicating with the API
@@ -266,7 +253,7 @@ def _pull_directory(
266253
try:
267254
logger.debug(f"`{path}`: Requesting page {page} of files")
268255
response = self.client.files.list_files(
269-
type=["prompt", "agent"],
256+
type=list(self.SERIALIZABLE_FILE_TYPES),
270257
page=page,
271258
include_raw_file_content=True,
272259
environment=environment,
@@ -281,20 +268,20 @@ def _pull_directory(
281268

282269
# Process each file
283270
for file in response.records:
284-
# Skip if not a Prompt or Agent
271+
# Skip if not a serializable file type
285272
if file.type not in self.SERIALIZABLE_FILE_TYPES:
286273
logger.warning(f"Skipping unsupported file type: {file.type}")
287274
continue
288275

289-
file_type: _SERIALIZABLE_FILE_TYPES_TYPE = typing.cast(
290-
_SERIALIZABLE_FILE_TYPES_TYPE,
276+
file_type: SerializableFileType = typing.cast(
277+
SerializableFileType,
291278
file.type,
292279
)
293280

294281
# Skip if no raw file content
295-
# @TODO: @ale, inconsistent, other places we are throwing if file is not serialisable
296282
if not getattr(file, "raw_file_content", None) or not file.raw_file_content: # type: ignore [union-attr]
297-
logger.warning(f"No content found for {file.type} {getattr(file, 'id', '<unknown>')}")
283+
logger.warning(f"No content found for {file.type} {file.path}")
284+
failed_files.append(file.path)
298285
continue
299286

300287
try:
@@ -368,6 +355,9 @@ def pull(self, path: str | None = None, environment: str | None = None) -> Tuple
368355
logger.debug(f"Pulling directory: {normalized_path}")
369356
successful_files, failed_files = self._pull_directory(normalized_path, environment)
370357

358+
# Clear the cache at the end of each pull operation
359+
self.clear_cache()
360+
371361
duration_ms = int((time.time() - start_time) * 1000)
372362
logger.info(f"Pull completed in {duration_ms}ms: {len(successful_files)} files succeeded")
373363

0 commit comments

Comments
 (0)