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