Skip to content

Commit e3a524e

Browse files
committed
Merge custom code
1 parent b01e6f4 commit e3a524e

File tree

12 files changed

+1311
-20
lines changed

12 files changed

+1311
-20
lines changed

.fernignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ mypy.ini
1313
README.md
1414
src/humanloop/decorators
1515
src/humanloop/otel
16+
src/humanloop/sync
17+
src/humanloop/cli/
1618

1719
## Tests
1820

pyproject.toml

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
# This section is used by PyPI and follows PEP 621 for package metadata
12
[project]
23
name = "humanloop"
4+
description = "The Humanloop Python Library"
5+
authors = []
36

7+
# This section is used by Poetry for development and building
8+
# The metadata here is used during development but not published to PyPI
49
[tool.poetry]
510
name = "humanloop"
6-
version = "0.8.36"
7-
description = ""
11+
version = "0.8.36b1"
12+
description = "Humanloop Python SDK"
813
readme = "README.md"
914
authors = []
10-
keywords = []
15+
packages = [
16+
{ include = "humanloop", from = "src" },
17+
]
1118

1219
classifiers = [
1320
"Intended Audience :: Developers",
@@ -26,9 +33,6 @@ classifiers = [
2633
"Topic :: Software Development :: Libraries :: Python Modules",
2734
"Typing :: Typed"
2835
]
29-
packages = [
30-
{ include = "humanloop", from = "src"}
31-
]
3236

3337
[project.urls]
3438
Repository = 'https://github.com/humanloop/humanloop-python'
@@ -53,8 +57,9 @@ protobuf = ">=5.29.3"
5357
pydantic = ">= 1.9.2"
5458
pydantic-core = "^2.18.2"
5559
typing_extensions = ">= 4.0.0"
60+
click = "^8.0.0"
5661

57-
[tool.poetry.dev-dependencies]
62+
[tool.poetry.group.dev.dependencies]
5863
mypy = "1.0.1"
5964
pytest = "^7.4.0"
6065
pytest-asyncio = "^0.23.5"
@@ -86,7 +91,10 @@ plugins = ["pydantic.mypy"]
8691
[tool.ruff]
8792
line-length = 120
8893

94+
[tool.poetry.scripts]
95+
humanloop = "humanloop.cli.__main__:cli"
8996

9097
[build-system]
9198
requires = ["poetry-core"]
9299
build-backend = "poetry.core.masonry.api"
100+

src/humanloop/cli/__init__.py

Whitespace-only changes.

src/humanloop/cli/__main__.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import click
2+
import logging
3+
from pathlib import Path
4+
from typing import Optional, Callable
5+
from functools import wraps
6+
from dotenv import load_dotenv, find_dotenv
7+
import os
8+
import sys
9+
from humanloop import Humanloop
10+
from humanloop.sync.sync_client import SyncClient
11+
from datetime import datetime
12+
from humanloop.cli.progress import progress_context
13+
14+
# Set up logging
15+
logger = logging.getLogger(__name__)
16+
logger.setLevel(logging.INFO) # Set back to INFO level
17+
console_handler = logging.StreamHandler()
18+
formatter = logging.Formatter("%(message)s") # Simplified formatter
19+
console_handler.setFormatter(formatter)
20+
if not logger.hasHandlers():
21+
logger.addHandler(console_handler)
22+
23+
# Color constants
24+
SUCCESS_COLOR = "green"
25+
ERROR_COLOR = "red"
26+
INFO_COLOR = "blue"
27+
WARNING_COLOR = "yellow"
28+
29+
MAX_FILES_TO_DISPLAY = 10
30+
31+
def get_client(api_key: Optional[str] = None, env_file: Optional[str] = None, base_url: Optional[str] = None) -> Humanloop:
32+
"""Get a Humanloop client instance."""
33+
if not api_key:
34+
if env_file:
35+
load_dotenv(env_file)
36+
else:
37+
env_path = find_dotenv()
38+
if env_path:
39+
load_dotenv(env_path)
40+
else:
41+
if os.path.exists(".env"):
42+
load_dotenv(".env")
43+
else:
44+
load_dotenv()
45+
46+
api_key = os.getenv("HUMANLOOP_API_KEY")
47+
if not api_key:
48+
raise click.ClickException(
49+
click.style("No API key found. Set HUMANLOOP_API_KEY in .env file or environment, or use --api-key", fg=ERROR_COLOR)
50+
)
51+
52+
return Humanloop(api_key=api_key, base_url=base_url)
53+
54+
def common_options(f: Callable) -> Callable:
55+
"""Decorator for common CLI options."""
56+
@click.option(
57+
"--api-key",
58+
help="Humanloop API key. If not provided, uses HUMANLOOP_API_KEY from .env or environment.",
59+
default=None,
60+
show_default=False,
61+
)
62+
@click.option(
63+
"--env-file",
64+
help="Path to .env file. If not provided, looks for .env in current directory.",
65+
default=None,
66+
type=click.Path(exists=True),
67+
show_default=False,
68+
)
69+
@click.option(
70+
"--base-dir",
71+
help="Base directory for pulled files",
72+
default="humanloop",
73+
type=click.Path(),
74+
)
75+
@click.option(
76+
"--base-url",
77+
default=None,
78+
hidden=True,
79+
)
80+
@wraps(f)
81+
def wrapper(*args, **kwargs):
82+
return f(*args, **kwargs)
83+
return wrapper
84+
85+
def handle_sync_errors(f: Callable) -> Callable:
86+
"""Decorator for handling sync operation errors."""
87+
@wraps(f)
88+
def wrapper(*args, **kwargs):
89+
try:
90+
return f(*args, **kwargs)
91+
except Exception as e:
92+
click.echo(click.style(str(f"Error: {e}"), fg=ERROR_COLOR))
93+
sys.exit(1)
94+
return wrapper
95+
96+
@click.group(
97+
help="Humanloop CLI for managing sync operations.",
98+
context_settings={
99+
"help_option_names": ["-h", "--help"],
100+
"max_content_width": 100,
101+
}
102+
)
103+
def cli():
104+
"""Humanloop CLI for managing sync operations."""
105+
pass
106+
107+
@cli.command()
108+
@click.option(
109+
"--path",
110+
"-p",
111+
help="Path to pull (file or directory). If not provided, pulls everything. "
112+
"To pull a specific file, ensure the extension for the file is included (e.g. .prompt or .agent). "
113+
"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).",
114+
default=None,
115+
)
116+
@click.option(
117+
"--environment",
118+
"-e",
119+
help="Environment to pull from (e.g. 'production', 'staging')",
120+
default=None,
121+
)
122+
@click.option(
123+
"--verbose",
124+
"-v",
125+
is_flag=True,
126+
help="Show detailed information about the operation",
127+
)
128+
@handle_sync_errors
129+
@common_options
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+
):
139+
"""Pull prompt and agent files from Humanloop to your local filesystem.
140+
141+
\b
142+
This command will:
143+
1. Fetch prompt and agent files from your Humanloop workspace
144+
2. Save them to your local filesystem
145+
3. Maintain the same directory structure as in Humanloop
146+
4. Add appropriate file extensions (.prompt or .agent)
147+
148+
\b
149+
The files will be saved with the following structure:
150+
{base_dir}/
151+
├── prompts/
152+
│ ├── my_prompt.prompt
153+
│ └── nested/
154+
│ └── another_prompt.prompt
155+
└── agents/
156+
└── my_agent.agent
157+
158+
The operation will overwrite existing files with the latest version from Humanloop
159+
but will not delete local files that don't exist in the remote workspace.
160+
161+
Currently only supports syncing prompt and agent files. Other file types will be skipped."""
162+
client = get_client(api_key, env_file, base_url)
163+
sync_client = SyncClient(client, base_dir=base_dir, log_level=logging.DEBUG if verbose else logging.WARNING)
164+
165+
click.echo(click.style("Pulling files from Humanloop...", fg=INFO_COLOR))
166+
click.echo(click.style(f"Path: {path or '(root)'}", fg=INFO_COLOR))
167+
click.echo(click.style(f"Environment: {environment or '(default)'}", fg=INFO_COLOR))
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)
175+
176+
# Get metadata about the operation
177+
metadata = sync_client.metadata.get_last_operation()
178+
if metadata:
179+
# Determine if the operation was successful based on failed_files
180+
is_successful = not metadata.get('failed_files') and not metadata.get('error')
181+
duration_color = SUCCESS_COLOR if is_successful else ERROR_COLOR
182+
click.echo(click.style(f"Pull completed in {metadata['duration_ms']}ms", fg=duration_color))
183+
184+
if metadata['successful_files']:
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))
198+
if metadata['failed_files']:
199+
click.echo(click.style(f"\nFailed to pull {len(metadata['failed_files'])} files:", fg=ERROR_COLOR))
200+
for file in metadata['failed_files']:
201+
click.echo(click.style(f" ✗ {file}", fg=ERROR_COLOR))
202+
if metadata.get('error'):
203+
click.echo(click.style(f"\nError: {metadata['error']}", fg=ERROR_COLOR))
204+
205+
def format_timestamp(timestamp: str) -> str:
206+
"""Format timestamp to a more readable format."""
207+
try:
208+
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
209+
return dt.strftime('%Y-%m-%d %H:%M:%S')
210+
except (ValueError, AttributeError):
211+
return timestamp
212+
213+
@cli.command()
214+
@click.option(
215+
"--oneline",
216+
is_flag=True,
217+
help="Display history in a single line per operation",
218+
)
219+
@handle_sync_errors
220+
@common_options
221+
def history(api_key: Optional[str], env_file: Optional[str], base_dir: str, base_url: Optional[str], oneline: bool):
222+
"""Show sync operation history."""
223+
client = get_client(api_key, env_file, base_url)
224+
sync_client = SyncClient(client, base_dir=base_dir)
225+
226+
history = sync_client.metadata.get_history()
227+
if not history:
228+
click.echo(click.style("No sync operations found in history.", fg=WARNING_COLOR))
229+
return
230+
231+
if not oneline:
232+
click.echo(click.style("Sync Operation History:", fg=INFO_COLOR))
233+
click.echo(click.style("======================", fg=INFO_COLOR))
234+
235+
for op in history:
236+
if oneline:
237+
# Format: timestamp | operation_type | path | environment | duration_ms | status
238+
status = click.style("✓", fg=SUCCESS_COLOR) if not op['failed_files'] else click.style("✗", fg=ERROR_COLOR)
239+
click.echo(f"{format_timestamp(op['timestamp'])} | {op['operation_type']} | {op['path'] or '(root)'} | {op['environment'] or '-'} | {op['duration_ms']}ms | {status}")
240+
else:
241+
click.echo(click.style(f"\nOperation: {op['operation_type']}", fg=INFO_COLOR))
242+
click.echo(f"Timestamp: {format_timestamp(op['timestamp'])}")
243+
click.echo(f"Path: {op['path'] or '(root)'}")
244+
if op['environment']:
245+
click.echo(f"Environment: {op['environment']}")
246+
click.echo(f"Duration: {op['duration_ms']}ms")
247+
if op['successful_files']:
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))
249+
if op['failed_files']:
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))
251+
if op['error']:
252+
click.echo(click.style(f"Error: {op['error']}", fg=ERROR_COLOR))
253+
click.echo(click.style("----------------------", fg=INFO_COLOR))
254+
255+
if __name__ == "__main__":
256+
cli()

0 commit comments

Comments
 (0)