diff options
author | Martijn Pieters <mj@zopatista.com> | 2022-11-09 17:09:59 +0000 |
---|---|---|
committer | David Lord <davidism@gmail.com> | 2023-01-19 16:33:27 -0800 |
commit | a63679e77f9be2eb99e2f0884d617f9635a485e2 (patch) | |
tree | 2d5b955758989d8cda021a6dfa0cae03ebdad5e0 | |
parent | 085f414a046bd5f15dfa08bedd0aa50f25410520 (diff) | |
download | click-a63679e77f9be2eb99e2f0884d617f9635a485e2.tar.gz |
Type hinting: improve decorator annotations
A combination of overloads, TypeVar, ParamSpec and Concatenate make it possible to tell the type checker more about what kinds of callables are expected and what is being returned.
-rw-r--r-- | src/click/core.py | 22 | ||||
-rw-r--r-- | src/click/decorators.py | 158 | ||||
-rw-r--r-- | src/click/utils.py | 11 |
3 files changed, 135 insertions, 56 deletions
diff --git a/src/click/core.py b/src/click/core.py index 1a85bab..6164cf3 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -706,12 +706,30 @@ class Context: """ return type(self)(command, info_name=command.name, parent=self) + @t.overload + def invoke( + __self, # noqa: B902 + __callback: "t.Callable[..., V]", + *args: t.Any, + **kwargs: t.Any, + ) -> V: + ... + + @t.overload def invoke( __self, # noqa: B902 - __callback: t.Union["Command", t.Callable[..., t.Any]], + __callback: "Command", *args: t.Any, **kwargs: t.Any, ) -> t.Any: + ... + + def invoke( + __self, # noqa: B902 + __callback: t.Union["Command", "t.Callable[..., V]"], + *args: t.Any, + **kwargs: t.Any, + ) -> t.Union[t.Any, V]: """Invokes a command callback in exactly the way it expects. There are two ways to invoke this method: @@ -739,7 +757,7 @@ class Context: "The given command does not have a callback that can be invoked." ) else: - __callback = other_cmd.callback + __callback = t.cast("t.Callable[..., V]", other_cmd.callback) ctx = __self._make_sub_context(other_cmd) diff --git a/src/click/decorators.py b/src/click/decorators.py index 4f7ecbb..b8b2731 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -13,36 +13,44 @@ from .core import Parameter from .globals import get_current_context from .utils import echo -F = t.TypeVar("F", bound=t.Callable[..., t.Any]) -FC = t.TypeVar("FC", bound=t.Union[t.Callable[..., t.Any], Command]) +if t.TYPE_CHECKING: + import typing_extensions as te + P = te.ParamSpec("P") -def pass_context(f: F) -> F: +R = t.TypeVar("R") +T = t.TypeVar("T") +_AnyCallable = t.Callable[..., t.Any] +_Decorator: "te.TypeAlias" = t.Callable[[T], T] +FC = t.TypeVar("FC", bound=t.Union[_AnyCallable, Command]) + + +def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": """Marks a callback as wanting to receive the current context object as first argument. """ - def new_func(*args, **kwargs): # type: ignore + def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": return f(get_current_context(), *args, **kwargs) - return update_wrapper(t.cast(F, new_func), f) + return update_wrapper(new_func, f) -def pass_obj(f: F) -> F: +def pass_obj(f: "t.Callable[te.Concatenate[t.Any, P], R]") -> "t.Callable[P, R]": """Similar to :func:`pass_context`, but only pass the object on the context onwards (:attr:`Context.obj`). This is useful if that object represents the state of a nested system. """ - def new_func(*args, **kwargs): # type: ignore + def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": return f(get_current_context().obj, *args, **kwargs) - return update_wrapper(t.cast(F, new_func), f) + return update_wrapper(new_func, f) def make_pass_decorator( - object_type: t.Type[t.Any], ensure: bool = False -) -> "t.Callable[[F], F]": + object_type: t.Type[T], ensure: bool = False +) -> t.Callable[["t.Callable[te.Concatenate[T, P], R]"], "t.Callable[P, R]"]: """Given an object type this creates a decorator that will work similar to :func:`pass_obj` but instead of passing the object of the current context, it will find the innermost context of type @@ -65,10 +73,11 @@ def make_pass_decorator( remembered on the context if it's not there yet. """ - def decorator(f: F) -> F: - def new_func(*args, **kwargs): # type: ignore + def decorator(f: "t.Callable[te.Concatenate[T, P], R]") -> "t.Callable[P, R]": + def new_func(*args: "P.args", **kwargs: "P.kwargs") -> "R": ctx = get_current_context() + obj: t.Optional[T] if ensure: obj = ctx.ensure_object(object_type) else: @@ -83,14 +92,14 @@ def make_pass_decorator( return ctx.invoke(f, obj, *args, **kwargs) - return update_wrapper(t.cast(F, new_func), f) + return update_wrapper(new_func, f) return decorator def pass_meta_key( key: str, *, doc_description: t.Optional[str] = None -) -> "t.Callable[[F], F]": +) -> "t.Callable[[t.Callable[te.Concatenate[t.Any, P], R]], t.Callable[P, R]]": """Create a decorator that passes a key from :attr:`click.Context.meta` as the first argument to the decorated function. @@ -103,13 +112,13 @@ def pass_meta_key( .. versionadded:: 8.0 """ - def decorator(f: F) -> F: - def new_func(*args, **kwargs): # type: ignore + def decorator(f: "t.Callable[te.Concatenate[t.Any, P], R]") -> "t.Callable[P, R]": + def new_func(*args: "P.args", **kwargs: "P.kwargs") -> R: ctx = get_current_context() obj = ctx.meta[key] return ctx.invoke(f, obj, *args, **kwargs) - return update_wrapper(t.cast(F, new_func), f) + return update_wrapper(new_func, f) if doc_description is None: doc_description = f"the {key!r} key from :attr:`click.Context.meta`" @@ -124,35 +133,51 @@ def pass_meta_key( CmdType = t.TypeVar("CmdType", bound=Command) +# variant: no call, directly as decorator for a function. @t.overload -def command( - __func: t.Callable[..., t.Any], -) -> Command: +def command(name: _AnyCallable) -> Command: ... +# variant: with positional name and with positional or keyword cls argument: +# @command(namearg, CommandCls, ...) or @command(namearg, cls=CommandCls, ...) @t.overload def command( - name: t.Optional[str] = None, + name: t.Optional[str], + cls: t.Type[CmdType], **attrs: t.Any, -) -> t.Callable[..., Command]: +) -> t.Callable[[_AnyCallable], CmdType]: ... +# variant: name omitted, cls _must_ be a keyword argument, @command(cmd=CommandCls, ...) +# The correct way to spell this overload is to use keyword-only argument syntax: +# def command(*, cls: t.Type[CmdType], **attrs: t.Any) -> ... +# However, mypy thinks this doesn't fit the overloaded function. Pyright does +# accept that spelling, and the following work-around makes pyright issue a +# warning that CmdType could be left unsolved, but mypy sees it as fine. *shrug* @t.overload def command( - name: t.Optional[str] = None, + name: None = None, cls: t.Type[CmdType] = ..., **attrs: t.Any, -) -> t.Callable[..., CmdType]: +) -> t.Callable[[_AnyCallable], CmdType]: + ... + + +# variant: with optional string name, no cls argument provided. +@t.overload +def command( + name: t.Optional[str] = ..., cls: None = None, **attrs: t.Any +) -> t.Callable[[_AnyCallable], Command]: ... def command( - name: t.Union[str, t.Callable[..., t.Any], None] = None, - cls: t.Optional[t.Type[Command]] = None, + name: t.Union[t.Optional[str], _AnyCallable] = None, + cls: t.Optional[t.Type[CmdType]] = None, **attrs: t.Any, -) -> t.Union[Command, t.Callable[..., Command]]: +) -> t.Union[Command, t.Callable[[_AnyCallable], t.Union[Command, CmdType]]]: r"""Creates a new :class:`Command` and uses the decorated function as callback. This will also automatically attach all decorated :func:`option`\s and :func:`argument`\s as parameters to the command. @@ -182,7 +207,7 @@ def command( appended to the end of the list. """ - func: t.Optional[t.Callable[..., t.Any]] = None + func: t.Optional[t.Callable[[_AnyCallable], t.Any]] = None if callable(name): func = name @@ -191,9 +216,9 @@ def command( assert not attrs, "Use 'command(**kwargs)(callable)' to provide arguments." if cls is None: - cls = Command + cls = t.cast(t.Type[CmdType], Command) - def decorator(f: t.Callable[..., t.Any]) -> Command: + def decorator(f: _AnyCallable) -> CmdType: if isinstance(f, Command): raise TypeError("Attempted to convert a callback into a command twice.") @@ -211,8 +236,12 @@ def command( if attrs.get("help") is None: attrs["help"] = f.__doc__ - cmd = cls( # type: ignore[misc] - name=name or f.__name__.lower().replace("_", "-"), # type: ignore[arg-type] + if t.TYPE_CHECKING: + assert cls is not None + assert not callable(name) + + cmd = cls( + name=name or f.__name__.lower().replace("_", "-"), callback=f, params=params, **attrs, @@ -226,24 +255,54 @@ def command( return decorator +GrpType = t.TypeVar("GrpType", bound=Group) + + +# variant: no call, directly as decorator for a function. +@t.overload +def group(name: _AnyCallable) -> Group: + ... + + +# variant: with positional name and with positional or keyword cls argument: +# @group(namearg, GroupCls, ...) or @group(namearg, cls=GroupCls, ...) @t.overload def group( - __func: t.Callable[..., t.Any], -) -> Group: + name: t.Optional[str], + cls: t.Type[GrpType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], GrpType]: ... +# variant: name omitted, cls _must_ be a keyword argument, @group(cmd=GroupCls, ...) +# The _correct_ way to spell this overload is to use keyword-only argument syntax: +# def group(*, cls: t.Type[GrpType], **attrs: t.Any) -> ... +# However, mypy thinks this doesn't fit the overloaded function. Pyright does +# accept that spelling, and the following work-around makes pyright issue a +# warning that GrpType could be left unsolved, but mypy sees it as fine. *shrug* @t.overload def group( - name: t.Optional[str] = None, + name: None = None, + cls: t.Type[GrpType] = ..., **attrs: t.Any, -) -> t.Callable[[F], Group]: +) -> t.Callable[[_AnyCallable], GrpType]: ... +# variant: with optional string name, no cls argument provided. +@t.overload def group( - name: t.Union[str, t.Callable[..., t.Any], None] = None, **attrs: t.Any -) -> t.Union[Group, t.Callable[[F], Group]]: + name: t.Optional[str] = ..., cls: None = None, **attrs: t.Any +) -> t.Callable[[_AnyCallable], Group]: + ... + + +def group( + name: t.Union[str, _AnyCallable, None] = None, + cls: t.Optional[t.Type[GrpType]] = None, + **attrs: t.Any, +) -> t.Union[Group, t.Callable[[_AnyCallable], t.Union[Group, GrpType]]]: """Creates a new :class:`Group` with a function as callback. This works otherwise the same as :func:`command` just that the `cls` parameter is set to :class:`Group`. @@ -251,17 +310,16 @@ def group( .. versionchanged:: 8.1 This decorator can be applied without parentheses. """ - if attrs.get("cls") is None: - attrs["cls"] = Group + if cls is None: + cls = t.cast(t.Type[GrpType], Group) if callable(name): - grp: t.Callable[[F], Group] = t.cast(Group, command(**attrs)) - return grp(name) + return command(cls=cls, **attrs)(name) - return t.cast(Group, command(name, **attrs)) + return command(name, cls, **attrs) -def _param_memo(f: FC, param: Parameter) -> None: +def _param_memo(f: t.Callable[..., t.Any], param: Parameter) -> None: if isinstance(f, Command): f.params.append(param) else: @@ -271,7 +329,7 @@ def _param_memo(f: FC, param: Parameter) -> None: f.__click_params__.append(param) # type: ignore -def argument(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: +def argument(*param_decls: str, **attrs: t.Any) -> _Decorator[FC]: """Attaches an argument to the command. All positional arguments are passed as parameter declarations to :class:`Argument`; all keyword arguments are forwarded unchanged (except ``cls``). @@ -290,7 +348,7 @@ def argument(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: return decorator -def option(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: +def option(*param_decls: str, **attrs: t.Any) -> _Decorator[FC]: """Attaches an option to the command. All positional arguments are passed as parameter declarations to :class:`Option`; all keyword arguments are forwarded unchanged (except ``cls``). @@ -311,7 +369,7 @@ def option(*param_decls: str, **attrs: t.Any) -> t.Callable[[FC], FC]: return decorator -def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: +def confirmation_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]: """Add a ``--yes`` option which shows a prompt before continuing if not passed. If the prompt is declined, the program will exit. @@ -335,7 +393,7 @@ def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], return option(*param_decls, **kwargs) -def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: +def password_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]: """Add a ``--password`` option which prompts for a password, hiding input and asking to enter the value again for confirmation. @@ -359,7 +417,7 @@ def version_option( prog_name: t.Optional[str] = None, message: t.Optional[str] = None, **kwargs: t.Any, -) -> t.Callable[[FC], FC]: +) -> _Decorator[FC]: """Add a ``--version`` option which immediately prints the version number and exits the program. @@ -466,7 +524,7 @@ def version_option( return option(*param_decls, **kwargs) -def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: +def help_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]: """Add a ``--help`` option which immediately prints the help page and exits the program. diff --git a/src/click/utils.py b/src/click/utils.py index 8f3fb57..e9310e5 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -21,23 +21,26 @@ from .globals import resolve_color_default if t.TYPE_CHECKING: import typing_extensions as te -F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + P = te.ParamSpec("P") + +R = t.TypeVar("R") def _posixify(name: str) -> str: return "-".join(name.split()).lower() -def safecall(func: F) -> F: +def safecall(func: "t.Callable[P, R]") -> "t.Callable[P, t.Optional[R]]": """Wraps a function so that it swallows exceptions.""" - def wrapper(*args, **kwargs): # type: ignore + def wrapper(*args: "P.args", **kwargs: "P.kwargs") -> t.Optional[R]: try: return func(*args, **kwargs) except Exception: pass + return None - return update_wrapper(t.cast(F, wrapper), func) + return update_wrapper(wrapper, func) def make_str(value: t.Any) -> str: |