From a1093bbe0dae00eea8342247a0c2739b07a6acd8 Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Tue, 8 Nov 2022 19:44:29 +0000 Subject: [PATCH 1/5] Types: don't leave generic types without a parameter Enable `disallow_any_generics` and provide type information for missing parameters for type hints. --- setup.cfg | 1 + src/click/_compat.py | 36 ++++++++++++++++++------------------ src/click/_termui_impl.py | 2 +- src/click/core.py | 10 +++++----- src/click/decorators.py | 2 +- src/click/exceptions.py | 4 ++-- src/click/testing.py | 12 ++++++------ src/click/types.py | 10 +++++----- src/click/utils.py | 18 ++++++++++-------- 9 files changed, 49 insertions(+), 46 deletions(-) diff --git a/setup.cfg b/setup.cfg index ea0a52c73..fa4ff5d74 100644 --- a/setup.cfg +++ b/setup.cfg @@ -85,6 +85,7 @@ disallow_subclassing_any = True disallow_untyped_calls = True disallow_untyped_defs = True disallow_incomplete_defs = True +disallow_any_generics = True check_untyped_defs = True no_implicit_optional = True local_partial_types = True diff --git a/src/click/_compat.py b/src/click/_compat.py index 766d286be..57faa9161 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -50,7 +50,7 @@ def is_ascii_encoding(encoding: str) -> bool: return False -def get_best_encoding(stream: t.IO) -> str: +def get_best_encoding(stream: t.IO[t.Any]) -> str: """Returns the default stream encoding if not found.""" rv = getattr(stream, "encoding", None) or sys.getdefaultencoding() if is_ascii_encoding(rv): @@ -153,7 +153,7 @@ def seekable(self) -> bool: return True -def _is_binary_reader(stream: t.IO, default: bool = False) -> bool: +def _is_binary_reader(stream: t.IO[t.Any], default: bool = False) -> bool: try: return isinstance(stream.read(0), bytes) except Exception: @@ -162,7 +162,7 @@ def _is_binary_reader(stream: t.IO, default: bool = False) -> bool: # closed. In this case, we assume the default. -def _is_binary_writer(stream: t.IO, default: bool = False) -> bool: +def _is_binary_writer(stream: t.IO[t.Any], default: bool = False) -> bool: try: stream.write(b"") except Exception: @@ -175,7 +175,7 @@ def _is_binary_writer(stream: t.IO, default: bool = False) -> bool: return True -def _find_binary_reader(stream: t.IO) -> t.Optional[t.BinaryIO]: +def _find_binary_reader(stream: t.IO[t.Any]) -> t.Optional[t.BinaryIO]: # We need to figure out if the given stream is already binary. # This can happen because the official docs recommend detaching # the streams to get binary streams. Some code might do this, so @@ -193,7 +193,7 @@ def _find_binary_reader(stream: t.IO) -> t.Optional[t.BinaryIO]: return None -def _find_binary_writer(stream: t.IO) -> t.Optional[t.BinaryIO]: +def _find_binary_writer(stream: t.IO[t.Any]) -> t.Optional[t.BinaryIO]: # We need to figure out if the given stream is already binary. # This can happen because the official docs recommend detaching # the streams to get binary streams. Some code might do this, so @@ -241,11 +241,11 @@ def _is_compatible_text_stream( def _force_correct_text_stream( - text_stream: t.IO, + text_stream: t.IO[t.Any], encoding: t.Optional[str], errors: t.Optional[str], - is_binary: t.Callable[[t.IO, bool], bool], - find_binary: t.Callable[[t.IO], t.Optional[t.BinaryIO]], + is_binary: t.Callable[[t.IO[t.Any], bool], bool], + find_binary: t.Callable[[t.IO[t.Any]], t.Optional[t.BinaryIO]], force_readable: bool = False, force_writable: bool = False, ) -> t.TextIO: @@ -287,7 +287,7 @@ def _force_correct_text_stream( def _force_correct_text_reader( - text_reader: t.IO, + text_reader: t.IO[t.Any], encoding: t.Optional[str], errors: t.Optional[str], force_readable: bool = False, @@ -303,7 +303,7 @@ def _force_correct_text_reader( def _force_correct_text_writer( - text_writer: t.IO, + text_writer: t.IO[t.Any], encoding: t.Optional[str], errors: t.Optional[str], force_writable: bool = False, @@ -367,11 +367,11 @@ def get_text_stderr( def _wrap_io_open( - file: t.Union[str, os.PathLike, int], + file: t.Union[str, "os.PathLike[t.AnyStr]", int], mode: str, encoding: t.Optional[str], errors: t.Optional[str], -) -> t.IO: +) -> t.IO[t.Any]: """Handles not passing ``encoding`` and ``errors`` in binary mode.""" if "b" in mode: return open(file, mode) @@ -385,7 +385,7 @@ def open_stream( encoding: t.Optional[str] = None, errors: t.Optional[str] = "strict", atomic: bool = False, -) -> t.Tuple[t.IO, bool]: +) -> t.Tuple[t.IO[t.Any], bool]: binary = "b" in mode # Standard streams first. These are simple because they ignore the @@ -456,11 +456,11 @@ def open_stream( f = _wrap_io_open(fd, mode, encoding, errors) af = _AtomicFile(f, tmp_filename, os.path.realpath(filename)) - return t.cast(t.IO, af), True + return t.cast(t.IO[t.Any], af), True class _AtomicFile: - def __init__(self, f: t.IO, tmp_filename: str, real_filename: str) -> None: + def __init__(self, f: t.IO[t.Any], tmp_filename: str, real_filename: str) -> None: self._f = f self._tmp_filename = tmp_filename self._real_filename = real_filename @@ -494,7 +494,7 @@ def strip_ansi(value: str) -> str: return _ansi_re.sub("", value) -def _is_jupyter_kernel_output(stream: t.IO) -> bool: +def _is_jupyter_kernel_output(stream: t.IO[t.Any]) -> bool: while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)): stream = stream._stream @@ -502,7 +502,7 @@ def _is_jupyter_kernel_output(stream: t.IO) -> bool: def should_strip_ansi( - stream: t.Optional[t.IO] = None, color: t.Optional[bool] = None + stream: t.Optional[t.IO[t.Any]] = None, color: t.Optional[bool] = None ) -> bool: if color is None: if stream is None: @@ -576,7 +576,7 @@ def term_len(x: str) -> int: return len(strip_ansi(x)) -def isatty(stream: t.IO) -> bool: +def isatty(stream: t.IO[t.Any]) -> bool: try: return stream.isatty() except Exception: diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 4b979bcc1..1caaad864 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -93,7 +93,7 @@ def __init__( self.is_hidden = not isatty(self.file) self._last_line: t.Optional[str] = None - def __enter__(self) -> "ProgressBar": + def __enter__(self) -> "ProgressBar[V]": self.entered = True self.render_progress() return self diff --git a/src/click/core.py b/src/click/core.py index 5abfb0f3c..9aef380a9 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1841,7 +1841,7 @@ def command( if self.command_class and kwargs.get("cls") is None: kwargs["cls"] = self.command_class - func: t.Optional[t.Callable] = None + func: t.Optional[t.Callable[..., t.Any]] = None if args and callable(args[0]): assert ( @@ -1889,7 +1889,7 @@ def group( """ from .decorators import group - func: t.Optional[t.Callable] = None + func: t.Optional[t.Callable[..., t.Any]] = None if args and callable(args[0]): assert ( @@ -2260,7 +2260,7 @@ def type_cast_value(self, ctx: Context, value: t.Any) -> t.Any: if value is None: return () if self.multiple or self.nargs == -1 else None - def check_iter(value: t.Any) -> t.Iterator: + def check_iter(value: t.Any) -> t.Iterator[t.Any]: try: return _check_iter(value) except TypeError: @@ -2277,12 +2277,12 @@ def check_iter(value: t.Any) -> t.Iterator: ) elif self.nargs == -1: - def convert(value: t.Any) -> t.Tuple: + def convert(value: t.Any) -> t.Tuple[t.Any, ...]: return tuple(self.type(x, self, ctx) for x in check_iter(value)) else: # nargs > 1 - def convert(value: t.Any) -> t.Tuple: + def convert(value: t.Any) -> t.Tuple[t.Any, ...]: value = tuple(check_iter(value)) if len(value) != self.nargs: diff --git a/src/click/decorators.py b/src/click/decorators.py index 28618dc52..4f7ecbbba 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -41,7 +41,7 @@ def new_func(*args, **kwargs): # type: ignore def make_pass_decorator( - object_type: t.Type, ensure: bool = False + object_type: t.Type[t.Any], ensure: bool = False ) -> "t.Callable[[F], F]": """Given an object type this creates a decorator that will work similar to :func:`pass_obj` but instead of passing the object of the diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 9e20b3eb5..59b18c6cb 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -36,7 +36,7 @@ def format_message(self) -> str: def __str__(self) -> str: return self.message - def show(self, file: t.Optional[t.IO] = None) -> None: + def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None: if file is None: file = get_text_stderr() @@ -59,7 +59,7 @@ def __init__(self, message: str, ctx: t.Optional["Context"] = None) -> None: self.ctx = ctx self.cmd = self.ctx.command if self.ctx else None - def show(self, file: t.Optional[t.IO] = None) -> None: + def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None: if file is None: file = get_text_stderr() color = None diff --git a/src/click/testing.py b/src/click/testing.py index 244d326a4..7b6dd7f1c 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -79,11 +79,11 @@ def mode(self) -> str: def make_input_stream( - input: t.Optional[t.Union[str, bytes, t.IO]], charset: str + input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]], charset: str ) -> t.BinaryIO: # Is already an input stream. if hasattr(input, "read"): - rv = _find_binary_reader(t.cast(t.IO, input)) + rv = _find_binary_reader(t.cast(t.IO[t.Any], input)) if rv is not None: return rv @@ -206,7 +206,7 @@ def make_env( @contextlib.contextmanager def isolation( self, - input: t.Optional[t.Union[str, bytes, t.IO]] = None, + input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]] = None, env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, color: bool = False, ) -> t.Iterator[t.Tuple[io.BytesIO, t.Optional[io.BytesIO]]]: @@ -301,7 +301,7 @@ def _getchar(echo: bool) -> str: default_color = color def should_strip_ansi( - stream: t.Optional[t.IO] = None, color: t.Optional[bool] = None + stream: t.Optional[t.IO[t.Any]] = None, color: t.Optional[bool] = None ) -> bool: if color is None: return not default_color @@ -350,7 +350,7 @@ def invoke( self, cli: "BaseCommand", args: t.Optional[t.Union[str, t.Sequence[str]]] = None, - input: t.Optional[t.Union[str, bytes, t.IO]] = None, + input: t.Optional[t.Union[str, bytes, t.IO[t.Any]]] = None, env: t.Optional[t.Mapping[str, t.Optional[str]]] = None, catch_exceptions: bool = True, color: bool = False, @@ -449,7 +449,7 @@ def invoke( @contextlib.contextmanager def isolated_filesystem( - self, temp_dir: t.Optional[t.Union[str, os.PathLike]] = None + self, temp_dir: t.Optional[t.Union[str, "os.PathLike[str]"]] = None ) -> t.Iterator[str]: """A context manager that creates a temporary directory and changes the current working directory to it. This isolates tests diff --git a/src/click/types.py b/src/click/types.py index d948c7055..1b04e3766 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -397,7 +397,7 @@ def __repr__(self) -> str: class _NumberParamTypeBase(ParamType): - _number_class: t.ClassVar[t.Type] + _number_class: t.ClassVar[t.Type[t.Any]] def convert( self, value: t.Any, param: t.Optional["Parameter"], ctx: t.Optional["Context"] @@ -702,8 +702,8 @@ def convert( lazy = self.resolve_lazy_flag(value) if lazy: - f: t.IO = t.cast( - t.IO, + f: t.IO[t.Any] = t.cast( + t.IO[t.Any], LazyFile( value, self.mode, self.encoding, self.errors, atomic=self.atomic ), @@ -794,7 +794,7 @@ def __init__( readable: bool = True, resolve_path: bool = False, allow_dash: bool = False, - path_type: t.Optional[t.Type] = None, + path_type: t.Optional[t.Type[t.Any]] = None, executable: bool = False, ): self.exists = exists @@ -944,7 +944,7 @@ class Tuple(CompositeParamType): :param types: a list of types that should be used for the tuple items. """ - def __init__(self, types: t.Sequence[t.Union[t.Type, ParamType]]) -> None: + def __init__(self, types: t.Sequence[t.Union[t.Type[t.Any], ParamType]]) -> None: self.types = [convert_type(ty) for ty in types] def to_info_dict(self) -> t.Dict[str, t.Any]: diff --git a/src/click/utils.py b/src/click/utils.py index 8283788ac..fca3ebabb 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -120,7 +120,7 @@ def __init__( self.encoding = encoding self.errors = errors self.atomic = atomic - self._f: t.Optional[t.IO] + self._f: t.Optional[t.IO[t.Any]] if filename == "-": self._f, self.should_close = open_stream(filename, mode, encoding, errors) @@ -141,7 +141,7 @@ def __repr__(self) -> str: return repr(self._f) return f"" - def open(self) -> t.IO: + def open(self) -> t.IO[t.Any]: """Opens the file if it's not yet open. This call might fail with a :exc:`FileError`. Not handling this error will produce an error that Click shows. @@ -183,7 +183,7 @@ def __iter__(self) -> t.Iterator[t.AnyStr]: class KeepOpenFile: - def __init__(self, file: t.IO) -> None: + def __init__(self, file: t.IO[t.Any]) -> None: self._file = file def __getattr__(self, name: str) -> t.Any: @@ -340,7 +340,7 @@ def open_file( errors: t.Optional[str] = "strict", lazy: bool = False, atomic: bool = False, -) -> t.IO: +) -> t.IO[t.Any]: """Open a file, with extra behavior to handle ``'-'`` to indicate a standard stream, lazy open on write, and atomic write. Similar to the behavior of the :class:`~click.File` param type. @@ -370,18 +370,20 @@ def open_file( .. versionadded:: 3.0 """ if lazy: - return t.cast(t.IO, LazyFile(filename, mode, encoding, errors, atomic=atomic)) + return t.cast( + t.IO[t.Any], LazyFile(filename, mode, encoding, errors, atomic=atomic) + ) f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic) if not should_close: - f = t.cast(t.IO, KeepOpenFile(f)) + f = t.cast(t.IO[t.Any], KeepOpenFile(f)) return f def format_filename( - filename: t.Union[str, bytes, os.PathLike], shorten: bool = False + filename: t.Union[str, bytes, "os.PathLike[t.AnyStr]"], shorten: bool = False ) -> str: """Formats a filename for user display. The main purpose of this function is to ensure that the filename can be displayed at all. This @@ -458,7 +460,7 @@ class PacifyFlushWrapper: pipe, all calls and attributes are proxied. """ - def __init__(self, wrapped: t.IO) -> None: + def __init__(self, wrapped: t.IO[t.Any]) -> None: self.wrapped = wrapped def flush(self) -> None: From 085f414a046bd5f15dfa08bedd0aa50f25410520 Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Wed, 9 Nov 2022 17:03:49 +0000 Subject: [PATCH 2/5] Type hinting: Low-hanging fruit improvements Clean out a series of ignores, either by specifying types or by reworking code slightly the ignore is no longer needed. --- src/click/_compat.py | 2 +- src/click/_termui_impl.py | 2 +- src/click/core.py | 4 ++-- src/click/types.py | 11 ++++------- src/click/utils.py | 4 ++-- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/click/_compat.py b/src/click/_compat.py index 57faa9161..e55a71302 100644 --- a/src/click/_compat.py +++ b/src/click/_compat.py @@ -483,7 +483,7 @@ def __getattr__(self, name: str) -> t.Any: def __enter__(self) -> "_AtomicFile": return self - def __exit__(self, exc_type, exc_value, tb): # type: ignore + def __exit__(self, exc_type: t.Optional[t.Type[BaseException]], *_: t.Any) -> None: self.close(delete=exc_type is not None) def __repr__(self) -> str: diff --git a/src/click/_termui_impl.py b/src/click/_termui_impl.py index 1caaad864..a050471f2 100644 --- a/src/click/_termui_impl.py +++ b/src/click/_termui_impl.py @@ -98,7 +98,7 @@ def __enter__(self) -> "ProgressBar[V]": self.render_progress() return self - def __exit__(self, exc_type, exc_value, tb): # type: ignore + def __exit__(self, *_: t.Any) -> None: self.render_finish() def __iter__(self) -> t.Iterator[V]: diff --git a/src/click/core.py b/src/click/core.py index 9aef380a9..1a85bab25 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -455,7 +455,7 @@ def __enter__(self) -> "Context": push_context(self) return self - def __exit__(self, exc_type, exc_value, tb): # type: ignore + def __exit__(self, *_: t.Any) -> None: self._depth -= 1 if self._depth == 0: self.close() @@ -2817,7 +2817,7 @@ def get_default( if self.is_flag and not self.is_bool_flag: for param in ctx.command.params: if param.name == self.name and param.default: - return param.flag_value # type: ignore + return t.cast(Option, param).flag_value return None diff --git a/src/click/types.py b/src/click/types.py index 1b04e3766..57866ec19 100644 --- a/src/click/types.py +++ b/src/click/types.py @@ -702,17 +702,14 @@ def convert( lazy = self.resolve_lazy_flag(value) if lazy: - f: t.IO[t.Any] = t.cast( - t.IO[t.Any], - LazyFile( - value, self.mode, self.encoding, self.errors, atomic=self.atomic - ), + lf = LazyFile( + value, self.mode, self.encoding, self.errors, atomic=self.atomic ) if ctx is not None: - ctx.call_on_close(f.close_intelligently) # type: ignore + ctx.call_on_close(lf.close_intelligently) - return f + return t.cast(t.IO[t.Any], lf) f, should_close = open_stream( value, self.mode, self.encoding, self.errors, atomic=self.atomic diff --git a/src/click/utils.py b/src/click/utils.py index fca3ebabb..8f3fb5772 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -174,7 +174,7 @@ def close_intelligently(self) -> None: def __enter__(self) -> "LazyFile": return self - def __exit__(self, exc_type, exc_value, tb): # type: ignore + def __exit__(self, *_: t.Any) -> None: self.close_intelligently() def __iter__(self) -> t.Iterator[t.AnyStr]: @@ -192,7 +192,7 @@ def __getattr__(self, name: str) -> t.Any: def __enter__(self) -> "KeepOpenFile": return self - def __exit__(self, exc_type, exc_value, tb): # type: ignore + def __exit__(self, *_: t.Any) -> None: pass def __repr__(self) -> str: From a63679e77f9be2eb99e2f0884d617f9635a485e2 Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Wed, 9 Nov 2022 17:09:59 +0000 Subject: [PATCH 3/5] 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. --- src/click/core.py | 22 +++++- src/click/decorators.py | 158 +++++++++++++++++++++++++++------------- 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 1a85bab25..6164cf3e8 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -706,12 +706,30 @@ def _make_sub_context(self, command: "Command") -> "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 @@ def invoke( "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 4f7ecbbba..b8b273185 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -13,36 +13,44 @@ 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 new_func(ctx, *args, **kwargs): 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 new_func(*args, **kwargs): # type: ignore 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 new_func(*args, **kwargs): # type: ignore 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 decorator(f: t.Callable[..., t.Any]) -> 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 decorator(f: t.Callable[..., t.Any]) -> 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 decorator(f: 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 decorator(f: 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 callback(ctx: Context, param: Parameter, value: bool) -> None: 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 callback(ctx: Context, param: Parameter, value: bool) -> None: 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 8f3fb5772..e9310e548 100644 --- a/src/click/utils.py +++ b/src/click/utils.py @@ -21,23 +21,26 @@ 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: From a72b95e21d20e78bac0d5d4d3de69faa7ce22280 Mon Sep 17 00:00:00 2001 From: Martijn Pieters Date: Wed, 9 Nov 2022 17:12:07 +0000 Subject: [PATCH 4/5] Add a changelog note on the typehint improvements. --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index f5c916392..c60c12a59 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Version 8.1.4 Unreleased +- Improve type hinting for decorators and give all generic types parameters. + :issue:`2398` + Version 8.1.3 ------------- From 9afe27e283f1b8feed3f64884bd261d14f8ffc63 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 19 Jan 2023 16:31:41 -0800 Subject: [PATCH 5/5] ignore bugbear opinion --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index fa4ff5d74..0d9b7ac51 100644 --- a/setup.cfg +++ b/setup.cfg @@ -71,6 +71,9 @@ ignore = W503 # zip with strict=, requires python >= 3.10 B905 + # string formatting opinion, B028 renamed to B907 + B028 + B907 # up to 88 allowed by bugbear B950 max-line-length = 80 per-file-ignores =