Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix types, introduce type tests #2562

Merged
merged 11 commits into from
Jul 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down
2 changes: 0 additions & 2 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements/typing.in
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
mypy
pyright
9 changes: 8 additions & 1 deletion requirements/typing.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SHA1:7983aaa01d64547827c20395d77e248c41b2572f
# SHA1:0d25c235a98f3c8c55aefb59b91c82834e185f0a
#
# This file is autogenerated by pip-compile-multi
# To update, run:
Expand All @@ -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
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 9 additions & 14 deletions src/click/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])


Expand Down Expand Up @@ -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],
Comment on lines -162 to +157
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the same change should be made to group's third overload.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but I think it's going to be up to the community to fill out the tests and fix the fallout. This PR is mainly to set up the infrastructure for it.

**attrs: t.Any,
) -> t.Callable[[_AnyCallable], CmdType]:
...
Expand Down Expand Up @@ -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``).
Expand Down Expand Up @@ -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``).
Expand All @@ -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.

Expand All @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
45 changes: 45 additions & 0 deletions tests/typing/typing_aliased_group.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions tests/typing/typing_confirmation_option.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions tests/typing/typing_help_option.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 15 additions & 0 deletions tests/typing/typing_options.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions tests/typing/typing_password_option.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions tests/typing/typing_simple_example.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 15 additions & 0 deletions tests/typing/typing_version_option.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down