Skip to content

Commit e0a9d53

Browse files
committed
Improve logging in sync client and CLI
1 parent 010d82e commit e0a9d53

File tree

4 files changed

+202
-73
lines changed

4 files changed

+202
-73
lines changed

src/humanloop/cli/__main__.py

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from humanloop import Humanloop
1010
from humanloop.sync.sync_client import SyncClient
1111
from datetime import datetime
12+
from humanloop.cli.progress import progress_context
1213

1314
# Set up logging
1415
logger = logging.getLogger(__name__)
@@ -25,6 +26,8 @@
2526
INFO_COLOR = "blue"
2627
WARNING_COLOR = "yellow"
2728

29+
MAX_FILES_TO_DISPLAY = 10
30+
2831
def get_client(api_key: Optional[str] = None, env_file: Optional[str] = None, base_url: Optional[str] = None) -> Humanloop:
2932
"""Get a Humanloop client instance."""
3033
if not api_key:
@@ -65,7 +68,7 @@ def common_options(f: Callable) -> Callable:
6568
)
6669
@click.option(
6770
"--base-dir",
68-
help="Base directory for synced files",
71+
help="Base directory for pulled files",
6972
default="humanloop",
7073
type=click.Path(),
7174
)
@@ -116,9 +119,23 @@ def cli():
116119
help="Environment to pull from (e.g. 'production', 'staging')",
117120
default=None,
118121
)
122+
@click.option(
123+
"--verbose",
124+
"-v",
125+
is_flag=True,
126+
help="Show detailed progress information",
127+
)
119128
@handle_sync_errors
120129
@common_options
121-
def pull(path: Optional[str], environment: Optional[str], api_key: Optional[str], env_file: Optional[str], base_dir: str, base_url: Optional[str]):
130+
def pull(
131+
path: Optional[str],
132+
environment: Optional[str],
133+
api_key: Optional[str],
134+
env_file: Optional[str],
135+
base_dir: str,
136+
base_url: Optional[str],
137+
verbose: bool
138+
):
122139
"""Pull prompt and agent files from Humanloop to your local filesystem.
123140
124141
\b
@@ -143,29 +160,43 @@ def pull(path: Optional[str], environment: Optional[str], api_key: Optional[str]
143160
144161
Currently only supports syncing prompt and agent files. Other file types will be skipped."""
145162
client = get_client(api_key, env_file, base_url)
146-
sync_client = SyncClient(client, base_dir=base_dir)
163+
sync_client = SyncClient(client, base_dir=base_dir, log_level=logging.DEBUG if verbose else logging.WARNING)
147164

148165
click.echo(click.style("Pulling files from Humanloop...", fg=INFO_COLOR))
149-
150166
click.echo(click.style(f"Path: {path or '(root)'}", fg=INFO_COLOR))
151167
click.echo(click.style(f"Environment: {environment or '(default)'}", fg=INFO_COLOR))
152-
153-
successful_files = sync_client.pull(path, environment)
168+
169+
if verbose:
170+
# Don't use the spinner in verbose mode as the spinner and sync client logging compete
171+
successful_files = sync_client.pull(path, environment)
172+
else:
173+
with progress_context("Pulling files..."):
174+
successful_files = sync_client.pull(path, environment)
154175

155176
# Get metadata about the operation
156177
metadata = sync_client.metadata.get_last_operation()
157178
if metadata:
158179
# Determine if the operation was successful based on failed_files
159180
is_successful = not metadata.get('failed_files') and not metadata.get('error')
160181
duration_color = SUCCESS_COLOR if is_successful else ERROR_COLOR
161-
click.echo(click.style(f"\nSync completed in {metadata['duration_ms']}ms", fg=duration_color))
182+
click.echo(click.style(f"Pull completed in {metadata['duration_ms']}ms", fg=duration_color))
162183

163184
if metadata['successful_files']:
164-
click.echo(click.style(f"\nSuccessfully synced {len(metadata['successful_files'])} files:", fg=SUCCESS_COLOR))
165-
for file in metadata['successful_files']:
166-
click.echo(click.style(f" ✓ {file}", fg=SUCCESS_COLOR))
185+
click.echo(click.style(f"\nSuccessfully pulled {len(metadata['successful_files'])} files:", fg=SUCCESS_COLOR))
186+
187+
if verbose:
188+
for file in metadata['successful_files']:
189+
click.echo(click.style(f" ✓ {file}", fg=SUCCESS_COLOR))
190+
else:
191+
files_to_display = metadata['successful_files'][:MAX_FILES_TO_DISPLAY]
192+
for file in files_to_display:
193+
click.echo(click.style(f" ✓ {file}", fg=SUCCESS_COLOR))
194+
195+
if len(metadata['successful_files']) > MAX_FILES_TO_DISPLAY:
196+
remaining = len(metadata['successful_files']) - MAX_FILES_TO_DISPLAY
197+
click.echo(click.style(f" ...and {remaining} more", fg=SUCCESS_COLOR))
167198
if metadata['failed_files']:
168-
click.echo(click.style(f"\nFailed to sync {len(metadata['failed_files'])} files:", fg=ERROR_COLOR))
199+
click.echo(click.style(f"\nFailed to pull {len(metadata['failed_files'])} files:", fg=ERROR_COLOR))
169200
for file in metadata['failed_files']:
170201
click.echo(click.style(f" ✗ {file}", fg=ERROR_COLOR))
171202
if metadata.get('error'):
@@ -214,9 +245,9 @@ def history(api_key: Optional[str], env_file: Optional[str], base_dir: str, base
214245
click.echo(f"Environment: {op['environment']}")
215246
click.echo(f"Duration: {op['duration_ms']}ms")
216247
if op['successful_files']:
217-
click.echo(click.style(f"Successfully synced {len(op['successful_files'])} file{'' if len(op['successful_files']) == 1 else 's'}", fg=SUCCESS_COLOR))
248+
click.echo(click.style(f"Successfully {op['operation_type']}ed {len(op['successful_files'])} file{'' if len(op['successful_files']) == 1 else 's'}", fg=SUCCESS_COLOR))
218249
if op['failed_files']:
219-
click.echo(click.style(f"Failed to sync {len(op['failed_files'])} file{'' if len(op['failed_files']) == 1 else 's'}", fg=ERROR_COLOR))
250+
click.echo(click.style(f"Failed to {op['operation_type']}ed {len(op['failed_files'])} file{'' if len(op['failed_files']) == 1 else 's'}", fg=ERROR_COLOR))
220251
if op['error']:
221252
click.echo(click.style(f"Error: {op['error']}", fg=ERROR_COLOR))
222253
click.echo(click.style("----------------------", fg=INFO_COLOR))

src/humanloop/cli/progress.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import sys
2+
import time
3+
from typing import Optional, Callable, Any
4+
from threading import Thread, Event
5+
from contextlib import contextmanager
6+
7+
class Spinner:
8+
"""A simple terminal spinner for indicating progress."""
9+
10+
def __init__(
11+
self,
12+
message: str = "Loading...",
13+
delay: float = 0.1,
14+
spinner_chars: str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
15+
):
16+
self.message = message
17+
self.delay = delay
18+
self.spinner_chars = spinner_chars
19+
self.stop_event = Event()
20+
self.spinner_thread: Optional[Thread] = None
21+
22+
def _spin(self):
23+
"""The actual spinner animation."""
24+
i = 0
25+
while not self.stop_event.is_set():
26+
sys.stdout.write(f"\r{self.spinner_chars[i]} {self.message}")
27+
sys.stdout.flush()
28+
i = (i + 1) % len(self.spinner_chars)
29+
time.sleep(self.delay)
30+
31+
def start(self):
32+
"""Start the spinner animation."""
33+
self.stop_event.clear()
34+
self.spinner_thread = Thread(target=self._spin)
35+
self.spinner_thread.daemon = True
36+
self.spinner_thread.start()
37+
38+
def stop(self, final_message: Optional[str] = None):
39+
"""Stop the spinner and optionally display a final message."""
40+
if self.spinner_thread is None:
41+
return
42+
43+
self.stop_event.set()
44+
self.spinner_thread.join()
45+
46+
# Clear the spinner line
47+
sys.stdout.write("\r" + " " * (len(self.message) + 2) + "\r")
48+
49+
if final_message:
50+
print(final_message)
51+
sys.stdout.flush()
52+
53+
def update_message(self, message: str):
54+
"""Update the spinner message."""
55+
self.message = message
56+
57+
class ProgressTracker:
58+
"""A simple progress tracker that shows percentage completion."""
59+
60+
def __init__(
61+
self,
62+
total: int,
63+
message: str = "Progress",
64+
width: int = 40
65+
):
66+
self.total = total
67+
self.current = 0
68+
self.message = message
69+
self.width = width
70+
self.start_time = time.time()
71+
72+
def update(self, increment: int = 1):
73+
"""Update the progress."""
74+
self.current += increment
75+
self._display()
76+
77+
def _display(self):
78+
"""Display the current progress."""
79+
percentage = (self.current / self.total) * 100
80+
filled = int(self.width * self.current / self.total)
81+
bar = "█" * filled + "░" * (self.width - filled)
82+
83+
elapsed = time.time() - self.start_time
84+
if self.current > 0:
85+
rate = elapsed / self.current
86+
eta = rate * (self.total - self.current)
87+
time_str = f"ETA: {eta:.1f}s"
88+
else:
89+
time_str = "Calculating..."
90+
91+
sys.stdout.write(f"\r{self.message}: [{bar}] {percentage:.1f}% {time_str}")
92+
sys.stdout.flush()
93+
94+
def finish(self, final_message: Optional[str] = None):
95+
"""Complete the progress bar and optionally show a final message."""
96+
self._display()
97+
print() # New line
98+
if final_message:
99+
print(final_message)
100+
101+
@contextmanager
102+
def progress_context(message: str = "Loading...", success_message: str | None = None, error_message: str | None = None):
103+
"""Context manager for showing a spinner during an operation."""
104+
spinner = Spinner(message)
105+
spinner.start()
106+
try:
107+
yield spinner
108+
spinner.stop(success_message)
109+
except Exception as e:
110+
spinner.stop(error_message)
111+
raise
112+
113+
def with_progress(message: str = "Loading..."):
114+
"""Decorator to add a spinner to a function."""
115+
def decorator(func: Callable):
116+
def wrapper(*args, **kwargs):
117+
with progress_context(message) as spinner:
118+
return func(*args, **kwargs)
119+
return wrapper
120+
return decorator

src/humanloop/sync/metadata_handler.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def log_operation(
6161
successful_files: Optional[List[str]] = None,
6262
failed_files: Optional[List[str]] = None,
6363
error: Optional[str] = None,
64-
start_time: Optional[float] = None
64+
duration_ms: Optional[float] = None
6565
) -> None:
6666
"""Log a sync operation.
6767
@@ -75,7 +75,6 @@ def log_operation(
7575
start_time: Optional timestamp when the operation started (from time.time())
7676
"""
7777
current_time = datetime.now().isoformat()
78-
duration_ms = int((time.time() - (start_time or time.time())) * 1000) if start_time else 0
7978

8079
operation_data = {
8180
"timestamp": current_time,

0 commit comments

Comments
 (0)