Skip to content

Commit 7f89e45

Browse files
committed
improve error handling and cli message formatting
1 parent d5e2de1 commit 7f89e45

File tree

5 files changed

+157
-59
lines changed

5 files changed

+157
-59
lines changed

src/humanloop/cli/__main__.py

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from functools import wraps
66
from dotenv import load_dotenv, find_dotenv
77
import os
8+
import sys
89
from humanloop import Humanloop
910
from humanloop.sync.sync_client import SyncClient
1011
from datetime import datetime
@@ -18,6 +19,12 @@
1819
if not logger.hasHandlers():
1920
logger.addHandler(console_handler)
2021

22+
# Color constants
23+
SUCCESS_COLOR = "green"
24+
ERROR_COLOR = "red"
25+
INFO_COLOR = "blue"
26+
WARNING_COLOR = "yellow"
27+
2128
def get_client(api_key: Optional[str] = None, env_file: Optional[str] = None, base_url: Optional[str] = None) -> Humanloop:
2229
"""Get a Humanloop client instance."""
2330
if not api_key:
@@ -36,7 +43,7 @@ def get_client(api_key: Optional[str] = None, env_file: Optional[str] = None, ba
3643
api_key = os.getenv("HUMANLOOP_API_KEY")
3744
if not api_key:
3845
raise click.ClickException(
39-
"No API key found. Set HUMANLOOP_API_KEY in .env file or environment, or use --api-key"
46+
click.style("No API key found. Set HUMANLOOP_API_KEY in .env file or environment, or use --api-key", fg=ERROR_COLOR)
4047
)
4148

4249
return Humanloop(api_key=api_key, base_url=base_url)
@@ -47,21 +54,21 @@ def common_options(f: Callable) -> Callable:
4754
"--api-key",
4855
help="Humanloop API key. If not provided, uses HUMANLOOP_API_KEY from .env or environment.",
4956
default=None,
57+
show_default=False,
5058
)
5159
@click.option(
5260
"--env-file",
5361
help="Path to .env file. If not provided, looks for .env in current directory.",
5462
default=None,
5563
type=click.Path(exists=True),
64+
show_default=False,
5665
)
5766
@click.option(
5867
"--base-dir",
5968
help="Base directory for synced files",
6069
default="humanloop",
6170
type=click.Path(),
6271
)
63-
# Hidden option for internal use - allows overriding the Humanloop API base URL
64-
# Can be set via --base-url or HUMANLOOP_BASE_URL environment variable
6572
@click.option(
6673
"--base-url",
6774
default=None,
@@ -79,20 +86,29 @@ def wrapper(*args, **kwargs):
7986
try:
8087
return f(*args, **kwargs)
8188
except Exception as e:
82-
logger.error(f"Error during sync operation: {str(e)}")
83-
raise click.ClickException(str(e))
89+
click.echo(click.style(str(f"Error: {e}"), fg=ERROR_COLOR))
90+
sys.exit(1)
8491
return wrapper
8592

86-
@click.group()
87-
def cli():
93+
@click.group(
94+
help="Humanloop CLI for managing sync operations.",
95+
context_settings={
96+
"help_option_names": ["-h", "--help"],
97+
"max_content_width": 100,
98+
}
99+
)
100+
@common_options
101+
def cli(api_key: Optional[str], env_file: Optional[str], base_dir: str, base_url: Optional[str]):
88102
"""Humanloop CLI for managing sync operations."""
89103
pass
90104

91105
@cli.command()
92106
@click.option(
93107
"--path",
94108
"-p",
95-
help="Path to pull (file or directory). If not provided, pulls everything.",
109+
help="Path to pull (file or directory). If not provided, pulls everything. "+
110+
"To pull a specific file, ensure the extension for the file is included (e.g. .prompt or .agent). "+
111+
"To pull a directory, simply specify the path to the directory (e.g. abc/def to pull all files under abc/def and its subdirectories).",
96112
default=None,
97113
)
98114
@click.option(
@@ -101,37 +117,37 @@ def cli():
101117
help="Environment to pull from (e.g. 'production', 'staging')",
102118
default=None,
103119
)
104-
@common_options
105120
@handle_sync_errors
106121
def pull(path: Optional[str], environment: Optional[str], api_key: Optional[str], env_file: Optional[str], base_dir: str, base_url: Optional[str]):
107-
"""Pull files from Humanloop to local filesystem.
108-
109-
If PATH is provided and ends with .prompt or .agent, pulls that specific file.
110-
Otherwise, pulls all files under the specified directory path.
111-
If no PATH is provided, pulls all files from the root.
112-
"""
122+
"""Pull files from Humanloop to your local filesystem."""
113123
client = get_client(api_key, env_file, base_url)
114124
sync_client = SyncClient(client, base_dir=base_dir)
115125

116-
click.echo("Pulling files from Humanloop...")
126+
click.echo(click.style("Pulling files from Humanloop...", fg=INFO_COLOR))
117127

118-
click.echo(f"Path: {path or '(root)'}")
119-
click.echo(f"Environment: {environment or '(default)'}")
128+
click.echo(click.style(f"Path: {path or '(root)'}", fg=INFO_COLOR))
129+
click.echo(click.style(f"Environment: {environment or '(default)'}", fg=INFO_COLOR))
120130

121131
successful_files = sync_client.pull(path, environment)
122132

123133
# Get metadata about the operation
124134
metadata = sync_client.metadata.get_last_operation()
125135
if metadata:
126-
click.echo(f"\nSync completed in {metadata['duration_ms']}ms")
136+
# Determine if the operation was successful based on failed_files
137+
is_successful = not metadata.get('failed_files') and not metadata.get('error')
138+
duration_color = SUCCESS_COLOR if is_successful else ERROR_COLOR
139+
click.echo(click.style(f"\nSync completed in {metadata['duration_ms']}ms", fg=duration_color))
140+
127141
if metadata['successful_files']:
128-
click.echo(f"\nSuccessfully synced {len(metadata['successful_files'])} files:")
142+
click.echo(click.style(f"\nSuccessfully synced {len(metadata['successful_files'])} files:", fg=SUCCESS_COLOR))
129143
for file in metadata['successful_files']:
130-
click.echo(f" ✓ {file}")
144+
click.echo(click.style(f" ✓ {file}", fg=SUCCESS_COLOR))
131145
if metadata['failed_files']:
132-
click.echo(f"\nFailed to sync {len(metadata['failed_files'])} files:")
146+
click.echo(click.style(f"\nFailed to sync {len(metadata['failed_files'])} files:", fg=ERROR_COLOR))
133147
for file in metadata['failed_files']:
134-
click.echo(f" ✗ {file}")
148+
click.echo(click.style(f" ✗ {file}", fg=ERROR_COLOR))
149+
if metadata.get('error'):
150+
click.echo(click.style(f"\nError: {metadata['error']}", fg=ERROR_COLOR))
135151

136152
def format_timestamp(timestamp: str) -> str:
137153
"""Format timestamp to a more readable format."""
@@ -147,7 +163,6 @@ def format_timestamp(timestamp: str) -> str:
147163
is_flag=True,
148164
help="Display history in a single line per operation",
149165
)
150-
@common_options
151166
@handle_sync_errors
152167
def history(api_key: Optional[str], env_file: Optional[str], base_dir: str, base_url: Optional[str], oneline: bool):
153168
"""Show sync operation history."""
@@ -156,32 +171,32 @@ def history(api_key: Optional[str], env_file: Optional[str], base_dir: str, base
156171

157172
history = sync_client.metadata.get_history()
158173
if not history:
159-
click.echo("No sync operations found in history.")
174+
click.echo(click.style("No sync operations found in history.", fg=WARNING_COLOR))
160175
return
161176

162177
if not oneline:
163-
click.echo("Sync Operation History:")
164-
click.echo("======================")
178+
click.echo(click.style("Sync Operation History:", fg=INFO_COLOR))
179+
click.echo(click.style("======================", fg=INFO_COLOR))
165180

166181
for op in history:
167182
if oneline:
168183
# Format: timestamp | operation_type | path | environment | duration_ms | status
169-
status = "✓" if not op['failed_files'] else "✗"
184+
status = click.style("✓", fg=SUCCESS_COLOR) if not op['failed_files'] else click.style("✗", fg=ERROR_COLOR)
170185
click.echo(f"{format_timestamp(op['timestamp'])} | {op['operation_type']} | {op['path'] or '(root)'} | {op['environment'] or '-'} | {op['duration_ms']}ms | {status}")
171186
else:
172-
click.echo(f"\nOperation: {op['operation_type']}")
187+
click.echo(click.style(f"\nOperation: {op['operation_type']}", fg=INFO_COLOR))
173188
click.echo(f"Timestamp: {format_timestamp(op['timestamp'])}")
174189
click.echo(f"Path: {op['path'] or '(root)'}")
175190
if op['environment']:
176191
click.echo(f"Environment: {op['environment']}")
177192
click.echo(f"Duration: {op['duration_ms']}ms")
178193
if op['successful_files']:
179-
click.echo(f"Successfully synced {len(op['successful_files'])} file{'' if len(op['successful_files']) == 1 else 's'}")
194+
click.echo(click.style(f"Successfully synced {len(op['successful_files'])} file{'' if len(op['successful_files']) == 1 else 's'}", fg=SUCCESS_COLOR))
180195
if op['failed_files']:
181-
click.echo(f"Failed to sync {len(op['failed_files'])} file{'' if len(op['failed_files']) == 1 else 's'}")
196+
click.echo(click.style(f"Failed to sync {len(op['failed_files'])} file{'' if len(op['failed_files']) == 1 else 's'}", fg=ERROR_COLOR))
182197
if op['error']:
183-
click.echo(f"Error: {op['error']}")
184-
click.echo("----------------------")
198+
click.echo(click.style(f"Error: {op['error']}", fg=ERROR_COLOR))
199+
click.echo(click.style("----------------------", fg=INFO_COLOR))
185200

186201
if __name__ == "__main__":
187202
cli()

src/humanloop/overload.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import inspect
22
import logging
33
import types
4+
import warnings
45
from typing import TypeVar, Union, Literal, Optional
56
from pathlib import Path
67
from humanloop.context import (
78
get_decorator_context,
89
get_evaluation_context,
910
get_trace_id,
1011
)
11-
from humanloop.evals.run import HumanloopRuntimeError
12+
from humanloop.error import HumanloopRuntimeError
1213

1314
from humanloop.evaluators.client import EvaluatorsClient
1415
from humanloop.flows.client import FlowsClient
@@ -154,34 +155,35 @@ def overload_with_local_files(
154155
use_local_files: Whether to use local files
155156
156157
Raises:
157-
FileNotFoundError: If use_local_files is True and local file is not found
158-
IOError: If use_local_files is True and local file cannot be read
158+
HumanloopRuntimeError: If use_local_files is True and local file cannot be accessed
159159
"""
160160
original_call = client._call if hasattr(client, '_call') else client.call
161161
original_log = client._log if hasattr(client, '_log') else client.log
162162
file_type = _get_file_type_from_client(client)
163163

164164
def _overload(self, function_name: str, **kwargs) -> PromptCallResponse:
165+
if "id" and "path" in kwargs:
166+
raise HumanloopRuntimeError(f"Can only specify one of `id` or `path` when {function_name}ing a {file_type}")
165167
# Handle local files if enabled
166168
if use_local_files and "path" in kwargs:
167169
# Check if version_id or environment is specified
168170
has_version_info = "version_id" in kwargs or "environment" in kwargs
169171

170172
if has_version_info:
171-
logger.warning(
172-
"Ignoring local file for %s as version_id or environment was specified. "
173+
warnings.warn(
174+
f"Ignoring local file for {kwargs['path']} as version_id or environment was specified. "
173175
"Using remote version instead.",
174-
kwargs["path"]
176+
UserWarning
175177
)
176178
else:
177179
# Only use local file if no version info is specified
178180
normalized_path = sync_client._normalize_path(kwargs["path"])
179181
try:
180182
file_content = sync_client.get_file_content(normalized_path, file_type)
181183
kwargs[file_type] = file_content
182-
except (FileNotFoundError, IOError) as e:
184+
except (HumanloopRuntimeError) as e:
183185
# Re-raise with more context
184-
raise type(e)(f"Failed to use local file for {kwargs['path']}: {str(e)}")
186+
raise HumanloopRuntimeError(f"Failed to use local file for {kwargs['path']}: {str(e)}")
185187

186188
try:
187189
if function_name == "call":

src/humanloop/sync/sync_client.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from humanloop.types import FileType
66
from .metadata_handler import MetadataHandler
77
import time
8+
from humanloop.error import HumanloopRuntimeError
89

910
if TYPE_CHECKING:
1011
from humanloop.base_client import BaseHumanloop
@@ -67,16 +68,15 @@ def _get_file_content_impl(self, path: str, file_type: FileType) -> str:
6768
The file content
6869
6970
Raises:
70-
FileNotFoundError: If the file doesn't exist
71-
IOError: If there's an error reading the file
71+
HumanloopRuntimeError: If the file doesn't exist or can't be read
7272
"""
7373
# Construct path to local file
7474
local_path = self.base_dir / path
7575
# Add appropriate extension
7676
local_path = local_path.parent / f"{local_path.stem}.{file_type}"
7777

7878
if not local_path.exists():
79-
raise FileNotFoundError(f"Local file not found: {local_path}")
79+
raise HumanloopRuntimeError(f"Local file not found: {local_path}")
8080

8181
try:
8282
# Read the file content
@@ -85,7 +85,7 @@ def _get_file_content_impl(self, path: str, file_type: FileType) -> str:
8585
logger.debug(f"Using local file content from {local_path}")
8686
return file_content
8787
except Exception as e:
88-
raise IOError(f"Error reading local file {local_path}: {str(e)}")
88+
raise HumanloopRuntimeError(f"Error reading local file {local_path}: {str(e)}")
8989

9090
def get_file_content(self, path: str, file_type: FileType) -> str:
9191
"""Get the content of a file from cache or filesystem.
@@ -101,8 +101,7 @@ def get_file_content(self, path: str, file_type: FileType) -> str:
101101
The file content
102102
103103
Raises:
104-
FileNotFoundError: If the file doesn't exist
105-
IOError: If there's an error reading the file
104+
HumanloopRuntimeError: If the file doesn't exist or can't be read
106105
"""
107106
return self._get_file_content_impl(path, file_type)
108107

@@ -205,7 +204,7 @@ def _pull_file(self, path: str, environment: str | None = None) -> None:
205204
self._save_serialized_file(file.content, file.path, file.type)
206205

207206
def _pull_directory(self,
208-
path: str | None = None,
207+
directory: str | None = None,
209208
environment: str | None = None,
210209
) -> List[str]:
211210
"""Sync prompt and agent files from Humanloop to local filesystem.
@@ -219,6 +218,9 @@ def _pull_directory(self,
219218
220219
Returns:
221220
List of successfully processed file paths
221+
222+
Raises:
223+
Exception: If there is an error fetching files from Humanloop
222224
"""
223225
successful_files = []
224226
failed_files = []
@@ -231,7 +233,7 @@ def _pull_directory(self,
231233
page=page,
232234
include_content=True,
233235
environment=environment,
234-
directory=path
236+
directory=directory
235237
)
236238

237239
if len(response.records) == 0:
@@ -258,14 +260,14 @@ def _pull_directory(self,
258260

259261
page += 1
260262
except Exception as e:
261-
logger.error(f"Failed to fetch page {page}: {str(e)}")
262-
break
263+
raise HumanloopRuntimeError(f"Failed to fetch page {page}: {str(e)}")
263264

264-
# Log summary
265-
if successful_files:
266-
logger.info(f"\nSynced {len(successful_files)} files")
267-
if failed_files:
268-
logger.error(f"Failed to sync {len(failed_files)} files")
265+
# Log summary only if we have results
266+
if successful_files or failed_files:
267+
if successful_files:
268+
logger.info(f"\nSynced {len(successful_files)} files")
269+
if failed_files:
270+
logger.error(f"Failed to sync {len(failed_files)} files")
269271

270272
return successful_files
271273

@@ -286,6 +288,7 @@ def pull(self, path: str | None = None, environment: str | None = None) -> List[
286288
start_time = time.time()
287289
try:
288290
if path is None:
291+
# Pull all files from the root
289292
successful_files = self._pull_directory(None, environment)
290293
failed_files = [] # Failed files are already logged in _pull_directory
291294
else:

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def humanloop_client(request, api_keys: APIKeys) -> Humanloop:
196196
use_local_files = getattr(request, "param", False)
197197
return Humanloop(
198198
api_key=api_keys.humanloop,
199-
base_url="http://localhost:80/v5/",
199+
base_url="http://localhost:80/v5",
200200
use_local_files=use_local_files
201201
)
202202

0 commit comments

Comments
 (0)