diff --git a/CHANGES.rst b/CHANGES.rst index 83d959125..dc406e1bb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Version 8.1.5 Unreleased +- Fix type hints for ``@click.command()`` and ``@click.option()``. Introduce typing + tests. :issue:`2558` + Version 8.1.4 ------------- diff --git a/requirements/dev.txt b/requirements/dev.txt index ed462080a..1a4ecc7b5 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -30,8 +30,6 @@ filelock==3.12.2 # virtualenv identify==2.5.24 # via pre-commit -nodeenv==1.8.0 - # via pre-commit pip-compile-multi==2.6.3 # via -r requirements/dev.in pip-tools==6.13.0 diff --git a/requirements/typing.in b/requirements/typing.in index f0aa93ac8..a20f06c42 100644 --- a/requirements/typing.in +++ b/requirements/typing.in @@ -1 +1,2 @@ mypy +pyright diff --git a/requirements/typing.txt b/requirements/typing.txt index 13afd660e..f04fde5dd 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -1,4 +1,4 @@ -# SHA1:7983aaa01d64547827c20395d77e248c41b2572f +# SHA1:0d25c235a98f3c8c55aefb59b91c82834e185f0a # # This file is autogenerated by pip-compile-multi # To update, run: @@ -9,5 +9,12 @@ mypy==1.4.1 # via -r requirements/typing.in mypy-extensions==1.0.0 # via mypy +nodeenv==1.8.0 + # via pyright +pyright==1.1.317 + # via -r requirements/typing.in typing-extensions==4.6.3 # via mypy + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/setup.cfg b/setup.cfg index 7446f699b..d5fcdcf01 100644 --- a/setup.cfg +++ b/setup.cfg @@ -78,7 +78,7 @@ per-file-ignores = src/click/__init__.py: F401 [mypy] -files = src/click +files = src/click, tests/typing python_version = 3.7 show_error_codes = True disallow_subclassing_any = True diff --git a/src/click/decorators.py b/src/click/decorators.py index 797449d63..674aee53b 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -21,7 +21,6 @@ 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]) @@ -150,16 +149,12 @@ def command( ... -# 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* +# variant: name omitted, cls _must_ be a keyword argument, @command(cls=CommandCls, ...) @t.overload def command( name: None = None, - cls: t.Type[CmdType] = ..., + *, + cls: t.Type[CmdType], **attrs: t.Any, ) -> t.Callable[[_AnyCallable], CmdType]: ... @@ -331,7 +326,7 @@ def _param_memo(f: t.Callable[..., t.Any], param: Parameter) -> None: def argument( *param_decls: str, cls: t.Optional[t.Type[Argument]] = None, **attrs: t.Any -) -> _Decorator[FC]: +) -> t.Callable[[FC], 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``). @@ -359,7 +354,7 @@ def decorator(f: FC) -> FC: def option( *param_decls: str, cls: t.Optional[t.Type[Option]] = None, **attrs: t.Any -) -> _Decorator[FC]: +) -> t.Callable[[FC], 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``). @@ -385,7 +380,7 @@ def decorator(f: FC) -> FC: return decorator -def confirmation_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]: +def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: """Add a ``--yes`` option which shows a prompt before continuing if not passed. If the prompt is declined, the program will exit. @@ -409,7 +404,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: return option(*param_decls, **kwargs) -def password_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]: +def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: """Add a ``--password`` option which prompts for a password, hiding input and asking to enter the value again for confirmation. @@ -433,7 +428,7 @@ def version_option( prog_name: t.Optional[str] = None, message: t.Optional[str] = None, **kwargs: t.Any, -) -> _Decorator[FC]: +) -> t.Callable[[FC], FC]: """Add a ``--version`` option which immediately prints the version number and exits the program. @@ -539,7 +534,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: return option(*param_decls, **kwargs) -def help_option(*param_decls: str, **kwargs: t.Any) -> _Decorator[FC]: +def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: """Add a ``--help`` option which immediately prints the help page and exits the program. diff --git a/tests/typing/typing_aliased_group.py b/tests/typing/typing_aliased_group.py new file mode 100644 index 000000000..ccc706bc5 --- /dev/null +++ b/tests/typing/typing_aliased_group.py @@ -0,0 +1,45 @@ +"""Example from https://click.palletsprojects.com/en/8.1.x/advanced/#command-aliases""" +from __future__ import annotations + +from typing_extensions import assert_type + +import click + + +class AliasedGroup(click.Group): + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: + rv = click.Group.get_command(self, ctx, cmd_name) + if rv is not None: + return rv + matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + if not matches: + return None + elif len(matches) == 1: + return click.Group.get_command(self, ctx, matches[0]) + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") + + def resolve_command( + self, ctx: click.Context, args: list[str] + ) -> tuple[str | None, click.Command, list[str]]: + # always return the full command name + _, cmd, args = super().resolve_command(ctx, args) + assert cmd is not None + return cmd.name, cmd, args + + +@click.command(cls=AliasedGroup) +def cli() -> None: + pass + + +assert_type(cli, AliasedGroup) + + +@cli.command() +def push() -> None: + pass + + +@cli.command() +def pop() -> None: + pass diff --git a/tests/typing/typing_confirmation_option.py b/tests/typing/typing_confirmation_option.py new file mode 100644 index 000000000..09ecaa96d --- /dev/null +++ b/tests/typing/typing_confirmation_option.py @@ -0,0 +1,13 @@ +"""From https://click.palletsprojects.com/en/8.1.x/options/#yes-parameters""" +from typing_extensions import assert_type + +import click + + +@click.command() +@click.confirmation_option(prompt="Are you sure you want to drop the db?") +def dropdb() -> None: + click.echo("Dropped all tables!") + + +assert_type(dropdb, click.Command) diff --git a/tests/typing/typing_help_option.py b/tests/typing/typing_help_option.py new file mode 100644 index 000000000..6b822c131 --- /dev/null +++ b/tests/typing/typing_help_option.py @@ -0,0 +1,13 @@ +from typing_extensions import assert_type + +import click + + +@click.command() +@click.help_option("-h", "--help") +def hello() -> None: + """Simple program that greets NAME for a total of COUNT times.""" + click.echo("Hello!") + + +assert_type(hello, click.Command) diff --git a/tests/typing/typing_options.py b/tests/typing/typing_options.py new file mode 100644 index 000000000..6a6611928 --- /dev/null +++ b/tests/typing/typing_options.py @@ -0,0 +1,15 @@ +"""From https://click.palletsprojects.com/en/8.1.x/quickstart/#adding-parameters""" +from typing_extensions import assert_type + +import click + + +@click.command() +@click.option("--count", default=1, help="number of greetings") +@click.argument("name") +def hello(count: int, name: str) -> None: + for _ in range(count): + click.echo(f"Hello {name}!") + + +assert_type(hello, click.Command) diff --git a/tests/typing/typing_password_option.py b/tests/typing/typing_password_option.py new file mode 100644 index 000000000..8537e6ff5 --- /dev/null +++ b/tests/typing/typing_password_option.py @@ -0,0 +1,14 @@ +import codecs + +from typing_extensions import assert_type + +import click + + +@click.command() +@click.password_option() +def encrypt(password: str) -> None: + click.echo(f"encoded: to {codecs.encode(password, 'rot13')}") + + +assert_type(encrypt, click.Command) diff --git a/tests/typing/typing_simple_example.py b/tests/typing/typing_simple_example.py new file mode 100644 index 000000000..0a94b8cb7 --- /dev/null +++ b/tests/typing/typing_simple_example.py @@ -0,0 +1,16 @@ +"""The simple example from https://github.com/pallets/click#a-simple-example.""" +from typing_extensions import assert_type + +import click + + +@click.command() +@click.option("--count", default=1, help="Number of greetings.") +@click.option("--name", prompt="Your name", help="The person to greet.") +def hello(count: int, name: str) -> None: + """Simple program that greets NAME for a total of COUNT times.""" + for _ in range(count): + click.echo(f"Hello, {name}!") + + +assert_type(hello, click.Command) diff --git a/tests/typing/typing_version_option.py b/tests/typing/typing_version_option.py new file mode 100644 index 000000000..fd473a001 --- /dev/null +++ b/tests/typing/typing_version_option.py @@ -0,0 +1,15 @@ +""" +From https://click.palletsprojects.com/en/8.1.x/options/#callbacks-and-eager-options. +""" +from typing_extensions import assert_type + +import click + + +@click.command() +@click.version_option("0.1") +def hello() -> None: + click.echo("Hello World!") + + +assert_type(hello, click.Command) diff --git a/tox.ini b/tox.ini index 1b163ff5a..df5360228 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,10 @@ commands = pre-commit run --all-files [testenv:typing] deps = -r requirements/typing.txt -commands = mypy +commands = + mypy + pyright --verifytypes click + pyright tests/typing [testenv:docs] deps = -r requirements/docs.txt