1+ import logging
2+ import sys
3+ from enum import Enum
14from functools import wraps
2- from typing import Union
5+ from typing import Any , Union
36
47import typer
58from typing_extensions import get_origin
69
7- from groundlight import Groundlight
10+ from groundlight import ExperimentalApi , Groundlight
811from groundlight .client import ApiTokenError
912
13+ logger = logging .getLogger ("groundlight.sdk" )
14+
1015cli_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
1632def 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
74133def 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