Skip to content

Commit 9946172

Browse files
committed
Add custom code on top of autogenerated SDK
1 parent d917672 commit 9946172

24 files changed

+2736
-99
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ poetry.toml
77
.env
88
tests/assets/*.jsonl
99
tests/assets/*.parquet
10+
# Ignore humanloop directory which could mistakenly be committed when testing sync functionality as it's used as the default sync directory
11+
humanloop

pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
addopts = -n auto

src/humanloop/cli/__init__.py

Whitespace-only changes.

src/humanloop/cli/__main__.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import click
2+
import logging
3+
from typing import Optional, Callable
4+
from functools import wraps
5+
from dotenv import load_dotenv
6+
import os
7+
import sys
8+
from humanloop import Humanloop
9+
from humanloop.sync.sync_client import SyncClient
10+
import time
11+
12+
# Set up logging
13+
logger = logging.getLogger(__name__)
14+
logger.setLevel(logging.INFO) # Set back to INFO level
15+
console_handler = logging.StreamHandler()
16+
formatter = logging.Formatter("%(message)s") # Simplified formatter
17+
console_handler.setFormatter(formatter)
18+
if not logger.hasHandlers():
19+
logger.addHandler(console_handler)
20+
21+
# Color constants
22+
SUCCESS_COLOR = "green"
23+
ERROR_COLOR = "red"
24+
INFO_COLOR = "blue"
25+
WARNING_COLOR = "yellow"
26+
27+
28+
def load_api_key(env_file: Optional[str] = None) -> str:
29+
"""Load API key from .env file or environment variable.
30+
31+
Args:
32+
env_file: Optional path to .env file
33+
34+
Returns:
35+
str: The loaded API key
36+
37+
Raises:
38+
click.ClickException: If no API key is found
39+
"""
40+
# Try specific .env file if provided, otherwise default to .env in current directory
41+
if 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+
)
49+
else:
50+
load_dotenv() # Attempt to load from default .env in current directory
51+
52+
# Get API key from environment
53+
api_key = os.getenv("HUMANLOOP_API_KEY")
54+
if not api_key:
55+
raise click.ClickException(
56+
click.style(
57+
"No API key found. Set HUMANLOOP_API_KEY in .env file or environment, or use --api-key", fg=ERROR_COLOR
58+
)
59+
)
60+
61+
return api_key
62+
63+
64+
def get_client(
65+
api_key: Optional[str] = None, env_file: Optional[str] = None, base_url: Optional[str] = None
66+
) -> Humanloop:
67+
"""Instantiate a Humanloop client for the CLI.
68+
69+
Args:
70+
api_key: Optional API key provided directly
71+
env_file: Optional path to .env file
72+
base_url: Optional base URL for the API
73+
74+
Returns:
75+
Humanloop: Configured client instance
76+
77+
Raises:
78+
click.ClickException: If no API key is found
79+
"""
80+
if not api_key:
81+
api_key = load_api_key(env_file)
82+
return Humanloop(api_key=api_key, base_url=base_url)
83+
84+
85+
def common_options(f: Callable) -> Callable:
86+
"""Decorator for common CLI options."""
87+
88+
@click.option(
89+
"--api-key",
90+
help="Humanloop API key. If not provided, uses HUMANLOOP_API_KEY from .env or environment.",
91+
default=None,
92+
show_default=False,
93+
)
94+
@click.option(
95+
"--env-file",
96+
help="Path to .env file. If not provided, looks for .env in current directory.",
97+
default=None,
98+
type=click.Path(exists=True),
99+
show_default=False,
100+
)
101+
@click.option(
102+
"--local-files-directory",
103+
"--local-dir",
104+
help="Directory (relative to the current working directory) where Humanloop files are stored locally (default: humanloop/).",
105+
default="humanloop",
106+
type=click.Path(),
107+
)
108+
@click.option(
109+
"--base-url",
110+
default=None,
111+
hidden=True,
112+
)
113+
@wraps(f)
114+
def wrapper(*args, **kwargs):
115+
return f(*args, **kwargs)
116+
117+
return wrapper
118+
119+
120+
def handle_sync_errors(f: Callable) -> Callable:
121+
"""Decorator for handling sync operation errors.
122+
123+
If an error occurs in any operation that uses this decorator, it will be logged and the program will exit with a non-zero exit code.
124+
"""
125+
126+
@wraps(f)
127+
def wrapper(*args, **kwargs):
128+
try:
129+
return f(*args, **kwargs)
130+
except Exception as e:
131+
click.echo(click.style(str(f"Error: {e}"), fg=ERROR_COLOR))
132+
sys.exit(1)
133+
134+
return wrapper
135+
136+
137+
@click.group(
138+
help="Humanloop CLI for managing sync operations.",
139+
context_settings={
140+
"help_option_names": ["-h", "--help"],
141+
"max_content_width": 100,
142+
},
143+
)
144+
def cli(): # Does nothing because used as a group for other subcommands (pull, push, etc.)
145+
"""Humanloop CLI for managing sync operations."""
146+
pass
147+
148+
149+
@cli.command()
150+
@click.option(
151+
"--path",
152+
"-p",
153+
help="Path in the Humanloop workspace to pull from (file or directory). You can pull an entire directory (e.g. 'my/directory') "
154+
"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. "
155+
"If not specified, pulls from the root of the remote workspace.",
156+
default=None,
157+
)
158+
@click.option(
159+
"--environment",
160+
"-e",
161+
help="Environment to pull from (e.g. 'production', 'staging')",
162+
default=None,
163+
)
164+
@click.option(
165+
"--verbose",
166+
"-v",
167+
is_flag=True,
168+
help="Show detailed information about the operation",
169+
)
170+
@click.option(
171+
"--quiet",
172+
"-q",
173+
is_flag=True,
174+
help="Suppress output of successful files",
175+
)
176+
@handle_sync_errors
177+
@common_options
178+
def pull(
179+
path: Optional[str],
180+
environment: Optional[str],
181+
api_key: Optional[str],
182+
env_file: Optional[str],
183+
local_files_directory: str,
184+
base_url: Optional[str],
185+
verbose: bool,
186+
quiet: bool,
187+
):
188+
"""Pull Prompt and Agent files from Humanloop to your local filesystem.
189+
190+
\b
191+
This command will:
192+
1. Fetch Prompt and Agent files from your Humanloop workspace
193+
2. Save them to your local filesystem (directory specified by --local-files-directory, default: humanloop/)
194+
3. Maintain the same directory structure as in Humanloop
195+
4. Add appropriate file extensions (.prompt or .agent)
196+
197+
\b
198+
For example, with the default --local-files-directory=humanloop, files will be saved as:
199+
./humanloop/
200+
├── my_project/
201+
│ ├── prompts/
202+
│ │ ├── my_prompt.prompt
203+
│ │ └── nested/
204+
│ │ └── another_prompt.prompt
205+
│ └── agents/
206+
│ └── my_agent.agent
207+
└── another_project/
208+
└── prompts/
209+
└── other_prompt.prompt
210+
211+
\b
212+
If you specify --local-files-directory=data/humanloop, files will be saved in ./data/humanloop/ instead.
213+
214+
If a file exists both locally and in the Humanloop workspace, the local file will be overwritten
215+
with the version from Humanloop. Files that only exist locally will not be affected.
216+
217+
Currently only supports syncing Prompt and Agent files. Other file types will be skipped."""
218+
client = get_client(api_key, env_file, base_url)
219+
sync_client = SyncClient(
220+
client, base_dir=local_files_directory, log_level=logging.DEBUG if verbose else logging.WARNING
221+
)
222+
223+
click.echo(click.style("Pulling files from Humanloop...", fg=INFO_COLOR))
224+
click.echo(click.style(f"Path: {path or '(root)'}", fg=INFO_COLOR))
225+
click.echo(click.style(f"Environment: {environment or '(default)'}", fg=INFO_COLOR))
226+
227+
start_time = time.time()
228+
successful_files, failed_files = sync_client.pull(path, environment)
229+
duration_ms = int((time.time() - start_time) * 1000)
230+
231+
# Determine if the operation was successful based on failed_files
232+
is_successful = not failed_files
233+
duration_color = SUCCESS_COLOR if is_successful else ERROR_COLOR
234+
click.echo(click.style(f"Pull completed in {duration_ms}ms", fg=duration_color))
235+
236+
if successful_files and not quiet:
237+
click.echo(click.style(f"\nSuccessfully pulled {len(successful_files)} files:", fg=SUCCESS_COLOR))
238+
for file in successful_files:
239+
click.echo(click.style(f" ✓ {file}", fg=SUCCESS_COLOR))
240+
241+
if failed_files:
242+
click.echo(click.style(f"\nFailed to pull {len(failed_files)} files:", fg=ERROR_COLOR))
243+
for file in failed_files:
244+
click.echo(click.style(f" ✗ {file}", fg=ERROR_COLOR))
245+
246+
247+
if __name__ == "__main__":
248+
cli()

0 commit comments

Comments
 (0)