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 /src | |
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.
Diffstat (limited to 'src')
-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: |