Skip to content

Commit 0063a45

Browse files
committed
create humanloop cli and support pull operation
1 parent 941a52e commit 0063a45

File tree

6 files changed

+223
-14
lines changed

6 files changed

+223
-14
lines changed

.fernignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ README.md
1414
src/humanloop/decorators
1515
src/humanloop/otel
1616
src/humanloop/sync
17+
src/humanloop/cli/
1718

1819
## Tests
1920

poetry.lock

Lines changed: 41 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ keywords = ["ai", "machine-learning", "llm", "sdk", "humanloop"]
66

77
[tool.poetry]
88
name = "humanloop"
9-
version = "0.8.36"
10-
description = ""
11-
readme = "README.md"
12-
authors = []
9+
version = "0.1.0"
10+
description = "Humanloop Python SDK"
11+
authors = ["Your Name <your.email@example.com>"]
1312
keywords = []
13+
packages = [
14+
{ include = "humanloop", from = "src" },
15+
]
1416

1517
classifiers = [
1618
"Intended Audience :: Developers",
@@ -29,9 +31,6 @@ classifiers = [
2931
"Topic :: Software Development :: Libraries :: Python Modules",
3032
"Typing :: Typed"
3133
]
32-
packages = [
33-
{ include = "humanloop", from = "src"}
34-
]
3534

3635
[project.urls]
3736
Repository = 'https://github.com/humanloop/humanloop-python'
@@ -56,6 +55,8 @@ protobuf = ">=5.29.3"
5655
pydantic = ">= 1.9.2"
5756
pydantic-core = "^2.18.2"
5857
typing_extensions = ">= 4.0.0"
58+
click = "^8.0.0"
59+
setuptools = "^80.1.0"
5960

6061
[tool.poetry.group.dev.dependencies]
6162
mypy = "1.0.1"
@@ -72,7 +73,7 @@ openai = "^1.52.2"
7273
pandas = "^2.2.0"
7374
parse-type = ">=0.6.4"
7475
pyarrow = "^19.0.0"
75-
pytest-retry = "^1.6.3"
76+
pytest-retry = "1.6.3"
7677
python-dotenv = "^1.0.1"
7778
replicate = "^1.0.3"
7879
ruff = "^0.5.6"
@@ -89,7 +90,10 @@ plugins = ["pydantic.mypy"]
8990
[tool.ruff]
9091
line-length = 120
9192

93+
[tool.poetry.scripts]
94+
humanloop = "humanloop.cli.__main__:cli"
9295

9396
[build-system]
94-
requires = ["poetry-core"]
97+
requires = ["poetry-core>=1.0.0"]
9598
build-backend = "poetry.core.masonry.api"
99+

src/humanloop/cli/__init__.py

Whitespace-only changes.

src/humanloop/cli/__main__.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
from humanloop import Humanloop
9+
from humanloop.sync.sync_client import SyncClient
10+
11+
# Set up logging
12+
logger = logging.getLogger(__name__)
13+
logger.setLevel(logging.INFO) # Set back to INFO level
14+
console_handler = logging.StreamHandler()
15+
formatter = logging.Formatter("%(message)s") # Simplified formatter
16+
console_handler.setFormatter(formatter)
17+
if not logger.hasHandlers():
18+
logger.addHandler(console_handler)
19+
20+
def get_client(api_key: Optional[str] = None, env_file: Optional[str] = None, base_url: Optional[str] = None) -> Humanloop:
21+
"""Get a Humanloop client instance."""
22+
if not api_key:
23+
if env_file:
24+
load_dotenv(env_file)
25+
else:
26+
env_path = find_dotenv()
27+
if env_path:
28+
load_dotenv(env_path)
29+
else:
30+
if os.path.exists(".env"):
31+
load_dotenv(".env")
32+
else:
33+
load_dotenv()
34+
35+
api_key = os.getenv("HUMANLOOP_API_KEY")
36+
if not api_key:
37+
raise click.ClickException(
38+
"No API key found. Set HUMANLOOP_API_KEY in .env file or environment, or use --api-key"
39+
)
40+
41+
return Humanloop(api_key=api_key, base_url=base_url)
42+
43+
def common_options(f: Callable) -> Callable:
44+
"""Decorator for common CLI options."""
45+
@click.option(
46+
"--api-key",
47+
help="Humanloop API key. If not provided, uses HUMANLOOP_API_KEY from .env or environment.",
48+
default=None,
49+
)
50+
@click.option(
51+
"--env-file",
52+
help="Path to .env file. If not provided, looks for .env in current directory.",
53+
default=None,
54+
type=click.Path(exists=True),
55+
)
56+
@click.option(
57+
"--base-dir",
58+
help="Base directory for synced files",
59+
default="humanloop",
60+
type=click.Path(),
61+
)
62+
# Hidden option for internal use - allows overriding the Humanloop API base URL
63+
# Can be set via --base-url or HUMANLOOP_BASE_URL environment variable
64+
@click.option(
65+
"--base-url",
66+
default=None,
67+
hidden=True,
68+
)
69+
@wraps(f)
70+
def wrapper(*args, **kwargs):
71+
return f(*args, **kwargs)
72+
return wrapper
73+
74+
def handle_sync_errors(f: Callable) -> Callable:
75+
"""Decorator for handling sync operation errors."""
76+
@wraps(f)
77+
def wrapper(*args, **kwargs):
78+
try:
79+
return f(*args, **kwargs)
80+
except Exception as e:
81+
logger.error(f"Error during sync operation: {str(e)}")
82+
raise click.ClickException(str(e))
83+
return wrapper
84+
85+
@click.group()
86+
def cli():
87+
"""Humanloop CLI for managing sync operations."""
88+
pass
89+
90+
@cli.command()
91+
@click.option(
92+
"--path",
93+
"-p",
94+
help="Path to pull (file or directory). If not provided, pulls everything.",
95+
default=None,
96+
)
97+
@click.option(
98+
"--environment",
99+
"-e",
100+
help="Environment to pull from (e.g. 'production', 'staging')",
101+
default=None,
102+
)
103+
@common_options
104+
@handle_sync_errors
105+
def pull(path: Optional[str], environment: Optional[str], api_key: Optional[str], env_file: Optional[str], base_dir: str, base_url: Optional[str]):
106+
"""Pull files from Humanloop to local filesystem.
107+
108+
If PATH is provided and ends with .prompt or .agent, pulls that specific file.
109+
Otherwise, pulls all files under the specified directory path.
110+
If no PATH is provided, pulls all files from the root.
111+
"""
112+
client = get_client(api_key, env_file, base_url)
113+
sync_client = SyncClient(client, base_dir=base_dir)
114+
115+
click.echo("Pulling files from Humanloop...")
116+
117+
click.echo(f"Path: {path or '(root)'}")
118+
click.echo(f"Environment: {environment or '(default)'}")
119+
120+
successful_files = sync_client.pull(path, environment)
121+
122+
# Get metadata about the operation
123+
metadata = sync_client.metadata.get_last_operation()
124+
if metadata:
125+
click.echo(f"\nSync completed in {metadata['duration_ms']}ms")
126+
if metadata['successful_files']:
127+
click.echo(f"\nSuccessfully synced {len(metadata['successful_files'])} files:")
128+
for file in metadata['successful_files']:
129+
click.echo(f" ✓ {file}")
130+
if metadata['failed_files']:
131+
click.echo(f"\nFailed to sync {len(metadata['failed_files'])} files:")
132+
for file in metadata['failed_files']:
133+
click.echo(f" ✗ {file}")
134+
135+
@cli.command()
136+
@common_options
137+
@handle_sync_errors
138+
def history(api_key: Optional[str], env_file: Optional[str], base_dir: str, base_url: Optional[str]):
139+
"""Show sync operation history."""
140+
client = get_client(api_key, env_file, base_url)
141+
sync_client = SyncClient(client, base_dir=base_dir)
142+
143+
history = sync_client.metadata.get_history()
144+
if not history:
145+
click.echo("No sync operations found in history.")
146+
return
147+
148+
click.echo("Sync Operation History:")
149+
click.echo("======================")
150+
151+
for op in history:
152+
click.echo(f"\nOperation: {op['operation_type']}")
153+
click.echo(f"Timestamp: {op['timestamp']}")
154+
click.echo(f"Path: {op['path'] or '(root)'}")
155+
if op['environment']:
156+
click.echo(f"Environment: {op['environment']}")
157+
click.echo(f"Duration: {op['duration_ms']}ms")
158+
if op['successful_files']:
159+
click.echo(f"Successfully synced {len(op['successful_files'])} file{'' if len(op['successful_files']) == 1 else 's'}")
160+
if op['failed_files']:
161+
click.echo(f"Failed to sync {len(op['failed_files'])} file{'' if len(op['failed_files']) == 1 else 's'}")
162+
if op['error']:
163+
click.echo(f"Error: {op['error']}")
164+
click.echo("----------------------")
165+
166+
if __name__ == "__main__":
167+
cli()

src/humanloop/sync/sync_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ def pull(self, path: str | None = None, environment: str | None = None) -> List[
277277
failed_files = [] # Failed files are already logged in _pull_directory
278278
else:
279279
normalized_path = self._normalize_path(path)
280-
if self.is_file(path):
280+
if self.is_file(path.strip()):
281281
self._pull_file(normalized_path, environment)
282282
successful_files = [path]
283283
failed_files = []

0 commit comments

Comments
 (0)