summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartijn Pieters <mj@zopatista.com>2022-11-09 17:09:59 +0000
committerDavid Lord <davidism@gmail.com>2023-01-19 16:33:27 -0800
commita63679e77f9be2eb99e2f0884d617f9635a485e2 (patch)
tree2d5b955758989d8cda021a6dfa0cae03ebdad5e0
parent085f414a046bd5f15dfa08bedd0aa50f25410520 (diff)
downloadclick-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.py22
-rw-r--r--src/click/decorators.py158
-rw-r--r--src/click/utils.py11
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: