Skip to content

Commit 987cc17

Browse files
author
Tim Huff
committed
first commit
1 parent 2ee690d commit 987cc17

2 files changed

Lines changed: 85 additions & 14 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ packages = [
99
{include = "**/*.py", from = "src"},
1010
]
1111
readme = "README.md"
12-
version = "0.27.0"
12+
version = "0.28.0"
1313

1414
[tool.poetry.dependencies]
1515
# For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver

src/groundlight/cli.py

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
1+
import logging
2+
import sys
3+
from enum import Enum
14
from functools import wraps
2-
from typing import Union
5+
from typing import Any, Union
36

47
import typer
58
from typing_extensions import get_origin
69

7-
from groundlight import Groundlight
10+
from groundlight import ExperimentalApi, Groundlight
811
from groundlight.client import ApiTokenError
912

13+
logger = logging.getLogger("groundlight.sdk")
14+
1015
cli_app = typer.Typer(
1116
no_args_is_help=True,
1217
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800},
1318
)
1419

20+
experimental_app = typer.Typer(
21+
no_args_is_help=True,
22+
help="Experimental commands — may change or be removed without notice.",
23+
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 800},
24+
)
25+
cli_app.add_typer(experimental_app, name="experimental")
26+
cli_app.add_typer(experimental_app, name="exp")
27+
28+
29+
_CLI_PRIMITIVE_TYPES = (str, int, float, bool)
30+
1531

1632
def is_cli_supported_type(annotation):
1733
"""
@@ -21,15 +37,45 @@ def is_cli_supported_type(annotation):
2137
return annotation in (int, float, bool)
2238

2339

24-
def class_func_to_cli(method):
40+
def is_cli_representable(annotation) -> bool:
41+
"""Returns True if the annotation is a type Typer can natively represent as a CLI argument.
42+
43+
Primitive scalar types, Enum subclasses, and Union types (handled separately) are considered
44+
representable. Complex types like dict, list, bytes, and custom model classes are not.
45+
"""
46+
if annotation in _CLI_PRIMITIVE_TYPES:
47+
return True
48+
if isinstance(annotation, type) and issubclass(annotation, Enum):
49+
return True
50+
if get_origin(annotation) is Union:
51+
return True
52+
return False
53+
54+
55+
def _format_result(result: Any) -> str:
56+
"""Format a method return value for CLI output.
57+
58+
Pydantic models are serialized to indented JSON. Everything else falls back to str().
59+
"""
60+
try:
61+
return result.model_dump_json(indent=2)
62+
except AttributeError:
63+
return str(result)
64+
65+
66+
def class_func_to_cli(method, is_experimental: bool = False):
2567
"""
26-
Given the class method, create a method with the identical signature to provide the help documentation and
27-
but only instantiates the class when the method is actually called.
68+
Given a class method, return a wrapper function with the same signature that Typer can
69+
register as a CLI command. The wrapper instantiates ExperimentalApi at call time (which
70+
also provides all stable Groundlight methods via inheritance), so a single instantiation
71+
path serves both stable and experimental commands.
72+
73+
If is_experimental is True, a warning is printed to stderr before the method runs.
2874
"""
2975

30-
# We create a fake class and fake method so we have the correct annotations for typer to use
31-
# When we wrap the fake method, we only use the fake method's name to access the real method
32-
# and attach it to a Groundlight instance that we create at function call time
76+
# We create a fake class and fake method so we have the correct annotations for typer to use.
77+
# When we wrap the fake method, we only use the fake method's name to look up and call the
78+
# real method on an ExperimentalApi instance created at call time.
3379
class FakeClass:
3480
pass
3581

@@ -38,14 +84,22 @@ class FakeClass:
3884

3985
@wraps(fake_method)
4086
def wrapper(*args, **kwargs):
41-
gl = Groundlight()
42-
gl_method = vars(Groundlight)[fake_method.__name__]
43-
gl_bound_method = gl_method.__get__(gl, Groundlight) # pylint: disable=all
44-
print(gl_bound_method(*args, **kwargs)) # this is where we output to the console
87+
if is_experimental:
88+
print(
89+
f"Warning: '{fake_method.__name__}' is an experimental command and may change without notice.",
90+
file=sys.stderr,
91+
)
92+
gl = ExperimentalApi()
93+
bound_method = getattr(gl, fake_method.__name__)
94+
result = bound_method(*args, **kwargs)
95+
if result is not None:
96+
print(_format_result(result))
4597

4698
# not recommended practice to directly change annotations, but gets around Typer not supporting Union types
4799
cli_unsupported_params = []
48100
for name, annotation in method.__annotations__.items():
101+
if name == "return":
102+
continue
49103
if get_origin(annotation) is Union:
50104
# If we can submit a string, we take the string from the cli
51105
if str in annotation.__args__:
@@ -60,6 +114,11 @@ def wrapper(*args, **kwargs):
60114
break
61115
if not found_supported_type:
62116
cli_unsupported_params.append(name)
117+
elif is_experimental and not is_cli_representable(annotation):
118+
# For experimental methods only: proactively flag non-Union types that Typer cannot
119+
# represent (e.g. dict, list, custom models) so the caller can skip them gracefully
120+
# before Typer raises a deferred RuntimeError at cli_app() invocation time.
121+
cli_unsupported_params.append(name)
63122
# Ideally we could just not list the unsupported params, but it doesn't seem natively supported by Typer
64123
# and requires more metaprogamming than makes sense at the moment. For now, we require methods to support str
65124
for param in cli_unsupported_params:
@@ -72,12 +131,24 @@ def wrapper(*args, **kwargs):
72131

73132

74133
def groundlight():
134+
"""Entry point for the groundlight CLI."""
75135
try:
76-
# For each method in the Groundlight class, create a function that can be called from the command line
136+
stable_names = {n for n, m in vars(Groundlight).items() if callable(m) and not n.startswith("_")}
137+
77138
for name, method in vars(Groundlight).items():
78139
if callable(method) and not name.startswith("_"):
79140
cli_func = class_func_to_cli(method)
80141
cli_app.command()(cli_func)
142+
143+
for name, method in vars(ExperimentalApi).items():
144+
if not callable(method) or name.startswith("_") or name in stable_names:
145+
continue
146+
try:
147+
cli_func = class_func_to_cli(method, is_experimental=True)
148+
experimental_app.command()(cli_func)
149+
except Exception as e: # pylint: disable=broad-except
150+
logger.debug("Skipping experimental CLI command '%s': %s", name, e)
151+
81152
cli_app()
82153
except ApiTokenError as e:
83154
print(e)

0 commit comments

Comments
 (0)