Skip to content

Instantly share code, notes, and snippets.

@SylvanG
Created November 4, 2024 03:57
Show Gist options
  • Save SylvanG/3a42474a82c9d2916d9be34158e85eb5 to your computer and use it in GitHub Desktop.
Save SylvanG/3a42474a82c9d2916d9be34158e85eb5 to your computer and use it in GitHub Desktop.
CliHelper facilitates create commands that require a class instance and command on its method. It uses Typer as underlying CLI library. Typer provides infer arguments & options from Python Typing Annotaion. However, there is no good way to get rid of the object initialization and self instance pass. It will be redundant to write all arguments tw…
import functools
import inspect
from collections import OrderedDict, defaultdict
from collections.abc import Mapping
from inspect import Parameter
from typing import Callable, get_type_hints
import typer
def debug_anno(f):
print(f.__name__)
print("__annotations__:", f.__annotations__)
# sigature is not affeted by __annotations__
# CPython implementation detail: If the passed object has a __signature__ attribute, we may use it to create the signature. The exact semantics are an implementation detail and are subject to unannounced changes.
# https://docs.python.org/3/library/inspect.html#inspect.signature
print("sigature:", inspect.signature(f, eval_str=True))
# __signature__ by default doesn't exist, but can be set manually
print("__signature__:", getattr(f, "__signature__", None))
# got from __annotations__ instead of signature
print("type hints:", get_type_hints(f))
print()
# typer.completion is using inspect.signature to confirm the required arguments and options, and using get_type_hints to confirm the type
def requireParamsFrom[T, **P](x: Callable[P, T]):
def decorator[V](f: Callable[..., V]) -> Callable[P, V]:
# debug_anno(x)
# debug_anno(f)
# set annotation
x_annotation = (x.__init__ if inspect.isclass(x) else x).__annotations__
f_annotations = f.__annotations__.copy()
f_annotations.update(x_annotation)
f_annotations["return"] = f.__annotations__.get("return", None)
f.__annotations__ = f_annotations
# set signature
x_sig = inspect.signature(x)
x_sig_params = x_sig.parameters
if (params_iter := iter(x_sig_params.items())) and next(params_iter)[
0
] == "self":
# skip the first self
x_sig_params = OrderedDict(params_iter)
f_sig = inspect.signature(f)
f_sig_params = OrderedDict(
[
(name, p)
for name, p in f_sig.parameters.items()
if p.kind
in (
p.POSITIONAL_ONLY,
p.POSITIONAL_OR_KEYWORD,
p.KEYWORD_ONLY,
) # ignore *args & **kwargs
]
)
f_sig_params = _update_params(f_sig_params, x_sig_params)
# The signature parameters is required to ordered from postional then keywords
f.__signature__ = f_sig.replace(parameters=list(f_sig_params.values()))
# debug_anno(f)
return f
return decorator
def _update_params(
params: OrderedDict[str, Parameter],
from_params: Mapping[str, Parameter],
) -> OrderedDict[str, Parameter]:
"""Update params from from_params, as the order of positional -> keywords.
- param with the same name will be overriden by the value in from_params
- params is placed before from_params for the same Parameter Kind.
"""
to_be_updated = defaultdict[int, list[Parameter]](list)
for name, param in from_params.items():
if name in params:
params[name] = param
continue
to_be_updated[param.kind].append(param)
res: list[Parameter] = []
for kind in (
Parameter.POSITIONAL_ONLY,
Parameter.POSITIONAL_OR_KEYWORD,
Parameter.KEYWORD_ONLY,
): # ignore Parameter.VAR_KEYWORD and Parameter.VAR_POSITIONAL
res.extend(p for p in params.values() if p.kind is kind)
res.extend(to_be_updated[kind])
return OrderedDict((p.name, p) for p in res)
class CliHelper:
"""CliHelper facilitates create commands that require a class instance and command on its method. It uses Typer as underlying CLI library.
Typer provides infer arguments & options from Python Typing Annotaion. However, there is no good way to get rid of the object initialization and self instance pass. It will be redundant to write all arguments twice.
# Example Usage
# Create a class with method
---
cli = CliHelper()
cli.require_obj(VideoDurationRepairer) # all intialization parameters will be taken as the common arguments and options for all commands.
# the obj instance will be passed to the method
cli.create_command(VideoDurationRepairer.check_and_fix_video, "fix")
cli.create_command(VideoDurationRepairer.check_and_fix_videos, "fixall")
cli.app() # run typer app
"""
def __init__(self, app: typer.Typer | None = None):
self.app = app or typer.Typer()
self._obj_initializer: Callable | None = None
self._wrapped_callback: Callable[[], Callable] | None = None
def require_obj(self, custom_type: Callable) -> Callable:
@requireParamsFrom(custom_type)
def obj_initializer(ctx: typer.Context, *args, **kwargs):
ctx.obj = custom_type(*args, **kwargs)
self._obj_initializer = obj_initializer
if self._wrapped_callback:
return self._wrapped_callback()
else:
return self.app.callback()(obj_initializer)
def create_command(self, f: Callable, name: str | None = None, **kwargs):
@requireParamsFrom(f)
def cmd(ctx: typer.Context, *args, **kwargs):
f(ctx.obj, *args, **kwargs)
return self.app.command(name=name or f.__name__, **kwargs)(cmd)
def callback(
self, *args, **kwargs
): # TODO: copy annotaion from typer.Typer.callback
"""Has the same siginature as typer.Typer.callback. However, this callback method doesn't override original obj_initializer callback, but combine them togther."""
def wrap(f: Callable):
self._wrapped_callback = functools.partial(
self.callback(*args, **kwargs), f
)
if not self._obj_initializer:
return self.app.callback(*args, **kwargs)(f)
obj_initializer = self._obj_initializer
params = inspect.signature(f).parameters.copy()
ctx_param = self._get_context_param(params)
if ctx_param:
params.pop(ctx_param.name)
# check
self._check_no_position_only_params(params)
self._check_sig_params_conflict(
inspect.signature(obj_initializer).parameters, params
)
@self.app.callback(*args, **kwargs)
@requireParamsFrom(f)
@requireParamsFrom(obj_initializer)
def wrapped(ctx: typer.Context, *args, **kwargs):
splited_kwargs = {
k: kwargs.pop(k) for k in list(kwargs.keys()) if k in params
}
if ctx_param:
# even there are two parameters requiring typer.Context, only one will be set
ctx = kwargs.pop(ctx_param.name, None) or ctx
splited_kwargs[ctx_param.name] = ctx
obj_initializer(ctx, *args, **kwargs)
f(**splited_kwargs)
return wrapped
return wrap
def _check_sig_params_conflict(
self,
params1: Mapping[str, Parameter],
params2: Mapping[str, Parameter],
):
if intersects := set(params1.keys()).intersection(params2.keys()):
raise Exception(
"Callback has parameter conflicts with obj initializer (required object)",
intersects,
)
def _check_no_position_only_params(self, params: Mapping[str, Parameter]):
for param in params.values():
if param.kind is Parameter.POSITIONAL_ONLY:
raise Exception(
"POSITIONAL_ONLY parameter is not allowed in the callback when used together with object initializer",
param,
)
def _get_context_param(self, params: Mapping[str, Parameter]) -> Parameter | None:
for param in params.values():
if param.annotation is typer.Context:
return param
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment