From 2443fdbf299477ebd969e279e83e1f05e1ef8999 Mon Sep 17 00:00:00 2001 From: dwreeves Date: Wed, 4 Oct 2023 17:37:48 -0400 Subject: [PATCH 01/11] fix tests --- src/rich_click/rich_command.py | 30 ++++++++++++++++++++++++------ tests/test_exit_code.py | 4 ++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/rich_click/rich_command.py b/src/rich_click/rich_command.py index e3148b7a..c3c85fac 100644 --- a/src/rich_click/rich_command.py +++ b/src/rich_click/rich_command.py @@ -5,8 +5,9 @@ from typing import Any, Callable, cast, ClassVar, Optional, overload, Sequence, TextIO, Type, Union import click -from click.utils import _detect_program_name, _expand_args, PacifyFlushWrapper +from click.utils import make_str, PacifyFlushWrapper +from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X from rich_click.rich_click import rich_abort_error, rich_format_error, rich_format_help from rich_click.rich_context import RichContext from rich_click.rich_help_formatter import RichHelpFormatter @@ -66,18 +67,35 @@ def main( # The reason why is explained in a comment in click's source code in the "except Exit as e" block. if args is None: - args = sys.argv[1:] + if CLICK_IS_BEFORE_VERSION_8X: + from click.utils import get_os_args - if os.name == "nt" and windows_expand_args: - args = _expand_args(args) + args = get_os_args() + else: + args = sys.argv[1:] + + if os.name == "nt" and windows_expand_args: + from click.utils import _expand_args + + args = _expand_args(args) else: args = list(args) if prog_name is None: - prog_name = _detect_program_name() + if CLICK_IS_BEFORE_VERSION_8X: + prog_name = make_str(os.path.basename(sys.argv[0] if sys.argv else __file__)) + else: + from click.utils import _detect_program_name + + prog_name = _detect_program_name() # Process shell completion requests and exit early. - self._main_shell_completion(extra, prog_name, complete_var) + if CLICK_IS_BEFORE_VERSION_8X: + from click.core import _bashcomplete + + _bashcomplete(self, prog_name, complete_var) + else: + self._main_shell_completion(extra, prog_name, complete_var) try: try: diff --git a/tests/test_exit_code.py b/tests/test_exit_code.py index 2df49a62..1ce319e9 100644 --- a/tests/test_exit_code.py +++ b/tests/test_exit_code.py @@ -1,8 +1,10 @@ import sys +import pytest from click.testing import CliRunner from rich_click import command, group, pass_context, RichContext +from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X # Don't use the 'invoke' fixture because we want control over the standalone_mode kwarg. @@ -90,6 +92,7 @@ def subcommand(): assert res.exit_code == 0 +@pytest.mark.skipif(CLICK_IS_BEFORE_VERSION_8X, reason="Result does not have return_value attribute.") def test_command_return_value_is_exit_code_when_not_standalone(): for expected_exit_code in range(10): @@ -103,6 +106,7 @@ def cli(ctx: RichContext): assert res.return_value == expected_exit_code +@pytest.mark.skipif(CLICK_IS_BEFORE_VERSION_8X, reason="Result does not have return_value attribute.") def test_group_return_value_is_exit_code_when_not_standalone(): for expected_exit_code in range(10): From b21ace21af85b5bb54c78e77c68649c539b793d2 Mon Sep 17 00:00:00 2001 From: dwreeves Date: Wed, 4 Oct 2023 22:23:34 -0400 Subject: [PATCH 02/11] fix typing --- pyproject.toml | 4 +- src/rich_click/__init__.py | 148 +++----------------------- src/rich_click/cli.py | 10 +- src/rich_click/decorators.py | 124 +++++++++++++++++++++ src/rich_click/rich_help_formatter.py | 11 +- 5 files changed, 151 insertions(+), 146 deletions(-) create mode 100644 src/rich_click/decorators.py diff --git a/pyproject.toml b/pyproject.toml index 562b35d7..0b6c44d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,9 +19,11 @@ sections = [ profile = "black" [tool.mypy] -python_version = "3.7" +python_version = "3.8" ignore_missing_imports = "True" scripts_are_modules = "True" +# strict = "True" +# follow_imports = "skip" [tool.pyright] include = ["src"] diff --git a/src/rich_click/__init__.py b/src/rich_click/__init__.py index 147c2bc3..008da432 100644 --- a/src/rich_click/__init__.py +++ b/src/rich_click/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa: F401 """ rich-click is a minimal Python module to combine the efforts of the excellent packages 'rich' and 'click'. @@ -7,141 +8,16 @@ __version__ = "1.7.0dev" -from typing import Any, Callable, cast, Optional, overload, TYPE_CHECKING, Union +from click import * -from click import * # noqa: F401, F403 -from click import Command -from click import command as click_command -from click import Group -from click import group as click_group -from rich.console import Console +from . import rich_click as rich_click -from . import rich_click # noqa: F401 - -from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X as _CLICK_IS_BEFORE_VERSION_8X -from rich_click.rich_command import RichBaseCommand, RichCommand, RichGroup, RichMultiCommand # noqa: F401 -from rich_click.rich_context import RichContext -from rich_click.rich_help_configuration import RichHelpConfiguration - -# MyPy does not like star imports. Therefore when we are type checking, we import each individual module -# from click here. This way MyPy will recognize the import and not throw any errors. Furthermore, because of -# the TYPE_CHECKING check, it does not influence the start routine at all. -if TYPE_CHECKING: - from click import argument, Choice, option, pass_context, Path, version_option # noqa: F401 - - __all__ = [ - "argument", - "Choice", - "option", - "Path", - "version_option", - "group", - "command", - "rich_config", - "RichContext", - "RichHelpConfiguration", - "pass_context", - ] - - -def group(name=None, cls=RichGroup, **attrs) -> Callable[..., RichGroup]: - """ - Group decorator function. - - Defines the group() function so that it uses the RichGroup class by default. - """ - - def wrapper(fn): - if hasattr(fn, "__rich_context_settings__"): - rich_context_settings = getattr(fn, "__rich_context_settings__", {}) - console = rich_context_settings.get("rich_console", None) - help_config = rich_context_settings.get("help_config", None) - context_settings = attrs.get("context_settings", {}) - context_settings.update(rich_console=console, rich_help_config=help_config) - attrs.update(context_settings=context_settings) - del fn.__rich_context_settings__ - if callable(name) and cls: - group = click_group(cls=cls, **attrs)(name) - else: - group = click_group(name, cls=cls, **attrs) - cmd = cast(RichGroup, group(fn)) - return cmd - - return wrapper - - -def command(name=None, cls=RichCommand, **attrs) -> Callable[..., RichCommand]: - """ - Command decorator function. - - Defines the command() function so that it uses the RichCommand class by default. - """ - - def wrapper(fn): - if hasattr(fn, "__rich_context_settings__"): - rich_context_settings = getattr(fn, "__rich_context_settings__", {}) - console = rich_context_settings.get("rich_console", None) - help_config = rich_context_settings.get("help_config", None) - context_settings = attrs.get("context_settings", {}) - context_settings.update(rich_console=console, rich_help_config=help_config) - attrs.update(context_settings=context_settings) - del fn.__rich_context_settings__ - if callable(name) and cls: - command = click_command(cls=cls, **attrs)(name) - else: - command = click_command(name, cls=cls, **attrs) - cmd = cast(RichCommand, command(fn)) - return cmd - - return wrapper - - -class NotSupportedError(Exception): - """Not Supported Error.""" - - pass - - -def rich_config(console: Optional[Console] = None, help_config: Optional[RichHelpConfiguration] = None): - """Use decorator to configure Rich Click settings. - - Args: - console: A Rich Console that will be accessible from the `RichContext`, `RichCommand`, and `RichGroup` instances - Defaults to None. - help_config: Rich help configuration that is used internally to format help messages and exceptions - Defaults to None. - """ - if _CLICK_IS_BEFORE_VERSION_8X: - - def decorator_with_warning(obj): - import warnings - - warnings.warn( - "`rich_config()` does not work with versions of click prior to version 8.0.0." - " Please update to a newer version of click to use this functionality.", - RuntimeWarning, - ) - return obj - - return decorator_with_warning - - @overload - def decorator(obj: Union[RichCommand, RichGroup]) -> Union[RichCommand, RichGroup]: - ... - - @overload - def decorator(obj: Callable[..., Any]) -> Callable[..., Any]: - ... - - def decorator(obj): - if isinstance(obj, (RichCommand, RichGroup)): - obj.context_settings.update({"rich_console": console, "rich_help_config": help_config}) - elif callable(obj) and not isinstance(obj, (Command, Group)): - setattr(obj, "__rich_context_settings__", {"rich_console": console, "rich_help_config": help_config}) - else: - raise NotSupportedError("`rich_config` requires a `RichCommand` or `RichGroup`. Try using the cls keyword") - - decorator.__doc__ = obj.__doc__ - return obj - - return decorator +from rich_click.decorators import command as command +from rich_click.decorators import group as group +from rich_click.decorators import rich_config as rich_config +from rich_click.rich_command import RichBaseCommand as RichBaseCommand +from rich_click.rich_command import RichCommand as RichCommand +from rich_click.rich_command import RichGroup as RichGroup +from rich_click.rich_command import RichMultiCommand as RichMultiCommand +from rich_click.rich_context import RichContext as RichContext +from rich_click.rich_help_configuration import RichHelpConfiguration as RichHelpConfiguration diff --git a/src/rich_click/cli.py b/src/rich_click/cli.py index 0e1953ff..624cfe0c 100644 --- a/src/rich_click/cli.py +++ b/src/rich_click/cli.py @@ -9,7 +9,7 @@ from importlib.metadata import entry_points except ImportError: # Support Python <3.8 - from importlib_metadata import entry_points + from importlib_metadata import entry_points # type: ignore[no-redef] import click from rich.console import Console @@ -66,10 +66,10 @@ def patch() -> None: """Patch Click internals to use Rich-Click types.""" click.group = rich_group click.command = rich_command - click.Group = RichGroup - click.Command = RichCommand - click.BaseCommand = RichBaseCommand - click.RichMultiCommand = RichMultiCommand + click.Group = RichGroup # type: ignore[misc] + click.Command = RichCommand # type: ignore[misc] + click.BaseCommand = RichBaseCommand # type: ignore[misc] + click.MultiCommand = RichMultiCommand # type: ignore[misc] def main(args: Optional[List[str]] = None) -> Any: diff --git a/src/rich_click/decorators.py b/src/rich_click/decorators.py new file mode 100644 index 00000000..e8b4253a --- /dev/null +++ b/src/rich_click/decorators.py @@ -0,0 +1,124 @@ +""" +rich-click is a minimal Python module to combine the efforts of the excellent packages 'rich' and 'click'. + +The intention is to provide attractive help output from click, formatted with rich, with minimal +customisation required. +""" + +__version__ = "1.7.0dev" + +from typing import Any, Callable, cast, Optional, Type, TypeVar + +from click import Command +from click import command as click_command +from click import Group +from click import group as click_group +from rich.console import Console + +from . import rich_click # noqa: F401 + +from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X +from rich_click.rich_command import RichBaseCommand, RichCommand, RichGroup, RichMultiCommand # noqa: F401 +from rich_click.rich_help_configuration import RichHelpConfiguration + +# MyPy does not like star imports. Therefore when we are type checking, we import each individual module +# from click here. This way MyPy will recognize the import and not throw any errors. Furthermore, because of +# the TYPE_CHECKING check, it does not influence the start routine at all. + +F = TypeVar("F", bound=Callable[..., Any]) +FC = TypeVar("FC", RichCommand, Callable[..., Any]) + + +def group(name: Optional[str] = None, cls: Optional[Type[Group]] = RichGroup, **attrs: Any) -> Callable[[F], Group]: + """ + Group decorator function. + + Defines the group() function so that it uses the RichGroup class by default. + """ + + def wrapper(fn): + if hasattr(fn, "__rich_context_settings__"): + rich_context_settings = getattr(fn, "__rich_context_settings__", {}) + console = rich_context_settings.get("rich_console", None) + help_config = rich_context_settings.get("help_config", None) + context_settings = attrs.get("context_settings", {}) + context_settings.update(rich_console=console, rich_help_config=help_config) + attrs.update(context_settings=context_settings) + del fn.__rich_context_settings__ + if callable(name) and cls: + group = click_group(cls=cls, **attrs)(name) + else: + group = click_group(name, cls=cls, **attrs) + cmd = cast(RichGroup, group(fn)) + return cmd + + return wrapper + + +def command(name: str = None, cls: Optional[Type[Group]] = RichCommand, **attrs: Any) -> Callable[[F], Group]: + """ + Command decorator function. + + Defines the command() function so that it uses the RichCommand class by default. + """ + + def wrapper(fn): + if hasattr(fn, "__rich_context_settings__"): + rich_context_settings = getattr(fn, "__rich_context_settings__", {}) + console = rich_context_settings.get("rich_console", None) + help_config = rich_context_settings.get("help_config", None) + context_settings = attrs.get("context_settings", {}) + context_settings.update(rich_console=console, rich_help_config=help_config) + attrs.update(context_settings=context_settings) + del fn.__rich_context_settings__ + if callable(name) and cls: + command = click_command(cls=cls, **attrs)(name) + else: + command = click_command(name, cls=cls, **attrs) + cmd = cast(RichCommand, command(fn)) + return cmd + + return wrapper + + +class NotSupportedError(Exception): + """Not Supported Error.""" + + pass + + +def rich_config(console: Optional[Console] = None, help_config: Optional[RichHelpConfiguration] = None): + """Use decorator to configure Rich Click settings. + + Args: + console: A Rich Console that will be accessible from the `RichContext`, `RichCommand`, and `RichGroup` instances + Defaults to None. + help_config: Rich help configuration that is used internally to format help messages and exceptions + Defaults to None. + """ + if CLICK_IS_BEFORE_VERSION_8X: + + def decorator_with_warning(obj): + import warnings + + warnings.warn( + "`rich_config()` does not work with versions of click prior to version 8.0.0." + " Please update to a newer version of click to use this functionality.", + RuntimeWarning, + ) + return obj + + return decorator_with_warning + + def decorator(obj: FC) -> FC: + if isinstance(obj, (RichCommand, RichGroup)): + obj.context_settings.update({"rich_console": console, "rich_help_config": help_config}) + elif callable(obj) and not isinstance(obj, (Command, Group)): + setattr(obj, "__rich_context_settings__", {"rich_console": console, "rich_help_config": help_config}) + else: + raise NotSupportedError("`rich_config` requires a `RichCommand` or `RichGroup`. Try using the cls keyword") + + decorator.__doc__ = obj.__doc__ + return obj + + return decorator diff --git a/src/rich_click/rich_help_formatter.py b/src/rich_click/rich_help_formatter.py index 8238da7b..fe8afe25 100644 --- a/src/rich_click/rich_help_formatter.py +++ b/src/rich_click/rich_help_formatter.py @@ -1,6 +1,6 @@ import sys from io import StringIO -from typing import IO, Optional +from typing import Any, IO, Optional import click import rich @@ -80,9 +80,12 @@ class RichHelpFormatter(click.HelpFormatter): def __init__( self, - *args, + indent_increment: int = 2, + width: Optional[int] = None, + max_width: Optional[int] = None, + *args: Any, config: Optional[RichHelpConfiguration] = None, - **kwargs, + **kwargs: Any, ) -> None: """Create Rich Help Formatter. @@ -90,7 +93,7 @@ def __init__( config: Configuration. Defaults to None. """ - super().__init__(*args, **kwargs) + super().__init__(indent_increment, width, max_width, *args, **kwargs) self._rich_buffer = TerminalBuffer() self._config = config or get_module_config() self._console = create_console(self._config, self._rich_buffer) From 6368b4e819b554e56aed32dd671c4fa15d7d5524 Mon Sep 17 00:00:00 2001 From: dwreeves Date: Thu, 5 Oct 2023 00:47:21 -0400 Subject: [PATCH 03/11] more typing fixes --- .github/workflows/pre-commit.yml | 2 ++ .pre-commit-config.yaml | 6 ++-- src/rich_click/__init__.py | 31 +++++++++++++++-- src/rich_click/_compat_click.py | 1 + src/rich_click/cli.py | 5 ++- src/rich_click/decorators.py | 36 +++++++++++++++++--- src/rich_click/rich_click.py | 35 +++++++++++-------- src/rich_click/rich_command.py | 41 +++++++++++------------ src/rich_click/rich_context.py | 4 +-- src/rich_click/rich_help_configuration.py | 4 +-- tests/test_help.py | 4 ++- 11 files changed, 115 insertions(+), 54 deletions(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 19fd9439..bd4d66f9 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -9,4 +9,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v3 + - name: Install dependencies + run: pip install '.[dev]' - uses: pre-commit/action@v2.0.3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9cbc48f1..36bf3253 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,9 +47,9 @@ repos: name: mypy - Static type checking description: Mypy helps ensure that we use our functions and variables correctly by checking the types. entry: mypy - language: python + language: system types: [python] exclude: ^examples/|^tests/fixtures/ require_serial: true - additional_dependencies: - - mypy +# additional_dependencies: +# - mypy diff --git a/src/rich_click/__init__.py b/src/rich_click/__init__.py index 008da432..53da7276 100644 --- a/src/rich_click/__init__.py +++ b/src/rich_click/__init__.py @@ -12,12 +12,37 @@ from . import rich_click as rich_click -from rich_click.decorators import command as command -from rich_click.decorators import group as group +from rich_click.decorators import command as command # type: ignore[no-redef] +from rich_click.decorators import group as group # type: ignore[no-redef] +from rich_click.decorators import pass_context as pass_context # type: ignore[no-redef,assignment] from rich_click.decorators import rich_config as rich_config -from rich_click.rich_command import RichBaseCommand as RichBaseCommand from rich_click.rich_command import RichCommand as RichCommand from rich_click.rich_command import RichGroup as RichGroup from rich_click.rich_command import RichMultiCommand as RichMultiCommand from rich_click.rich_context import RichContext as RichContext from rich_click.rich_help_configuration import RichHelpConfiguration as RichHelpConfiguration + + +def __getattr__(name: str) -> object: + import click + + from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_9X + + if name == "RichMultiCommand" and CLICK_IS_BEFORE_VERSION_9X: + import warnings + + warnings.warn( + "'RichMultiCommand' is deprecated and will be removed in Click 9.0. Use" " 'RichGroup' instead.", + DeprecationWarning, + stacklevel=2, + ) + from rich_click.rich_command import RichMultiCommand + + return RichMultiCommand + + # Support for potentially deprecated objects in newer versions of click: + elif name in {"BaseCommand", "OptionParser", "MultiCommand"}: + return getattr(click, name) + + else: + raise AttributeError(name) diff --git a/src/rich_click/_compat_click.py b/src/rich_click/_compat_click.py index 1fa4c1c2..374ea8cb 100644 --- a/src/rich_click/_compat_click.py +++ b/src/rich_click/_compat_click.py @@ -11,6 +11,7 @@ CLICK_IS_BEFORE_VERSION_8X = _major < 8 +CLICK_IS_BEFORE_VERSION_9X = _major < 9 CLICK_IS_VERSION_80 = _major == 8 and _minor == 0 diff --git a/src/rich_click/cli.py b/src/rich_click/cli.py index 624cfe0c..8941f0f6 100644 --- a/src/rich_click/cli.py +++ b/src/rich_click/cli.py @@ -19,7 +19,7 @@ from rich_click import command as rich_command from rich_click import group as rich_group -from rich_click import RichBaseCommand, RichCommand, RichGroup, RichMultiCommand +from rich_click import RichCommand, RichGroup, RichMultiCommand from rich_click.rich_click import ( ALIGN_ERRORS_PANEL, ERRORS_PANEL_TITLE, @@ -68,7 +68,6 @@ def patch() -> None: click.command = rich_command click.Group = RichGroup # type: ignore[misc] click.Command = RichCommand # type: ignore[misc] - click.BaseCommand = RichBaseCommand # type: ignore[misc] click.MultiCommand = RichMultiCommand # type: ignore[misc] @@ -99,7 +98,7 @@ def main(args: Optional[List[str]] = None) -> Any: sys.exit(0) else: script_name = args[0] - scripts = {script.name: script for script in entry_points().get("console_scripts")} + scripts = {script.name: script for script in entry_points().get("console_scripts", [])} if script_name in scripts: # a valid script was passed script = scripts[script_name] diff --git a/src/rich_click/decorators.py b/src/rich_click/decorators.py index e8b4253a..b36b894b 100644 --- a/src/rich_click/decorators.py +++ b/src/rich_click/decorators.py @@ -7,18 +7,21 @@ __version__ = "1.7.0dev" -from typing import Any, Callable, cast, Optional, Type, TypeVar +from typing import Any, Callable, cast, Optional, Type, TypeVar, Union from click import Command from click import command as click_command -from click import Group +from click import Context, Group from click import group as click_group +from click import pass_context as click_pass_context from rich.console import Console +from typing_extensions import Concatenate, ParamSpec from . import rich_click # noqa: F401 from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X -from rich_click.rich_command import RichBaseCommand, RichCommand, RichGroup, RichMultiCommand # noqa: F401 +from rich_click.rich_command import RichCommand, RichGroup, RichMultiCommand # noqa: F401 +from rich_click.rich_context import RichContext from rich_click.rich_help_configuration import RichHelpConfiguration # MyPy does not like star imports. Therefore when we are type checking, we import each individual module @@ -26,7 +29,7 @@ # the TYPE_CHECKING check, it does not influence the start routine at all. F = TypeVar("F", bound=Callable[..., Any]) -FC = TypeVar("FC", RichCommand, Callable[..., Any]) +FC = TypeVar("FC", Command, Callable[..., Any]) def group(name: Optional[str] = None, cls: Optional[Type[Group]] = RichGroup, **attrs: Any) -> Callable[[F], Group]: @@ -55,7 +58,9 @@ def wrapper(fn): return wrapper -def command(name: str = None, cls: Optional[Type[Group]] = RichCommand, **attrs: Any) -> Callable[[F], Group]: +def command( + name: Optional[str] = None, cls: Optional[Type[Command]] = RichCommand, **attrs: Any +) -> Callable[[F], Command]: """ Command decorator function. @@ -122,3 +127,24 @@ def decorator(obj: FC) -> FC: return obj return decorator + + +# Users of rich_click would face issues using mypy with this code, +# if not for wrapping `pass_context` with a new function signature: +# +# @click.command() +# @click.pass_context +# def cli(ctx: click.RichContext) -> None: +# ... + + +P = ParamSpec("P") +R = TypeVar("R") +C = TypeVar("C", bound=Context) + + +def pass_context(f: Callable[Concatenate[C, P], R]) -> Callable[P, R]: + return click_pass_context(f) # type: ignore[arg-type] + + +pass_context.__doc__ = click_pass_context.__doc__ diff --git a/src/rich_click/rich_click.py b/src/rich_click/rich_click.py index 73ebf2fe..63c23a7c 100644 --- a/src/rich_click/rich_click.py +++ b/src/rich_click/rich_click.py @@ -1,7 +1,7 @@ import inspect import re from os import getenv -from typing import Dict, Iterable, List, Optional, Union +from typing import Dict, Iterable, List, Optional, TYPE_CHECKING, Union import click import rich.columns @@ -28,7 +28,7 @@ try: from rich.console import group except ImportError: - from rich.console import render_group as group + from rich.console import render_group as group # type: ignore[attr-defined,no-redef] # Default styles STYLE_OPTION = "bold cyan" @@ -51,7 +51,7 @@ STYLE_REQUIRED_SHORT = "red" STYLE_REQUIRED_LONG = "dim red" STYLE_OPTIONS_PANEL_BORDER = "dim" -ALIGN_OPTIONS_PANEL = "left" +ALIGN_OPTIONS_PANEL: Literal["left", "center", "right"] = "left" STYLE_OPTIONS_TABLE_SHOW_LINES = False STYLE_OPTIONS_TABLE_LEADING = 0 STYLE_OPTIONS_TABLE_PAD_EDGE = False @@ -60,7 +60,7 @@ STYLE_OPTIONS_TABLE_ROW_STYLES = None STYLE_OPTIONS_TABLE_BORDER_STYLE = None STYLE_COMMANDS_PANEL_BORDER = "dim" -ALIGN_COMMANDS_PANEL = "left" +ALIGN_COMMANDS_PANEL: Literal["left", "center", "right"] = "left" STYLE_COMMANDS_TABLE_SHOW_LINES = False STYLE_COMMANDS_TABLE_LEADING = 0 STYLE_COMMANDS_TABLE_PAD_EDGE = False @@ -70,7 +70,7 @@ STYLE_COMMANDS_TABLE_BORDER_STYLE = None STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (None, None) STYLE_ERRORS_PANEL_BORDER = "red" -ALIGN_ERRORS_PANEL = "left" +ALIGN_ERRORS_PANEL: Literal["left", "center", "right"] = "left" STYLE_ERRORS_SUGGESTION = "dim" STYLE_ABORTED = "red" WIDTH = int(getenv("TERMINAL_WIDTH")) if getenv("TERMINAL_WIDTH") else None # type: ignore @@ -196,6 +196,8 @@ def _get_help_text( Yields: Text or Markdown: Multiple styled objects (depreciated, usage) """ + if TYPE_CHECKING: + assert isinstance(obj.help, str) formatter = _get_rich_formatter(formatter) config = formatter.config # Prepend deprecated status @@ -251,7 +253,7 @@ def _get_parameter_help( """ formatter = _get_rich_formatter(formatter) config = formatter.config - items = [] + items: List[rich.console.RenderableType] = [] # Get the environment variable first envvar = getattr(param, "envvar", None) @@ -264,7 +266,7 @@ def _get_parameter_help( ): envvar = f"{ctx.auto_envvar_prefix}_{param.name.upper()}" if envvar is not None: - envvar = ", ".join(param.envvar) if type(envvar) is list else envvar + envvar = ", ".join(envvar) if type(envvar) is list else envvar # Environment variable config.before help text if getattr(param, "show_envvar", None) and config.option_envvar_first and envvar is not None: @@ -272,6 +274,9 @@ def _get_parameter_help( # Main help text if getattr(param, "help", None): + if TYPE_CHECKING: + assert hasattr(param, "help") + assert isinstance(param.help, str) paragraphs = param.help.split("\n\n") # Remove single linebreaks if not config.use_markdown: @@ -406,7 +411,7 @@ class UsageHighlighter(RegexHighlighter): def rich_format_help( - obj: Union[click.Command, click.Group], + obj: click.BaseCommand, ctx: click.Context, formatter: click.HelpFormatter, ) -> None: @@ -586,7 +591,7 @@ class MetavarHighlighter(RegexHighlighter): show_header=False, expand=True, box=box_style, - **t_styles, + **t_styles, # type: ignore[arg-type] ) # Strip the required column if none are required if all([x[0] == "" for x in options_rows]): @@ -606,7 +611,7 @@ class MetavarHighlighter(RegexHighlighter): # Groups only: # List click command groups # - if hasattr(obj, "list_commands"): + if isinstance(obj, click.MultiCommand): # Look through COMMAND_GROUPS for this command # stick anything unmatched into a default group at the end cmd_groups = config.command_groups.get(ctx.command_path, []).copy() @@ -638,7 +643,7 @@ class MetavarHighlighter(RegexHighlighter): show_header=False, expand=True, box=box_style, - **t_styles, + **t_styles, # type: ignore[arg-type] ) # Define formatting in first column, as commands don't match highlighter regex # and set column ratio for first and second column, if a ratio has been set @@ -676,7 +681,7 @@ class MetavarHighlighter(RegexHighlighter): ) # Epilogue if we have it - if obj.epilog: + if isinstance(obj, click.Command) and obj.epilog: # Remove single linebreaks, replace double with single lines = obj.epilog.split("\n\n") epilogue = "\n".join([x.replace("\n", " ").strip() for x in lines]) @@ -702,6 +707,8 @@ def rich_format_error(self: click.ClickException, formatter: Optional[RichHelpFo highlighter = formatter.config.highlighter # Print usage if getattr(self, "ctx", None) is not None: + if TYPE_CHECKING: + assert hasattr(self, "ctx") get_rich_usage(self.ctx.command, self.ctx, formatter) if config.errors_suggestion: console.print( @@ -714,12 +721,12 @@ def rich_format_error(self: click.ClickException, formatter: Optional[RichHelpFo elif ( config.errors_suggestion is None and getattr(self, "ctx", None) is not None - and self.ctx.command.get_help_option(self.ctx) is not None + and self.ctx.command.get_help_option(self.ctx) is not None # type: ignore[attr-defined] ): console.print( Padding( "Try [blue]'{command} {option}'[/] for help.".format( - command=self.ctx.command_path, option=self.ctx.help_option_names[0] + command=self.ctx.command_path, option=self.ctx.help_option_names[0] # type: ignore[attr-defined] ), (0, 1, 0, 1), ), diff --git a/src/rich_click/rich_command.py b/src/rich_click/rich_command.py index c3c85fac..95fe13a8 100644 --- a/src/rich_click/rich_command.py +++ b/src/rich_click/rich_command.py @@ -1,8 +1,9 @@ import errno import os import sys +import warnings from functools import wraps -from typing import Any, Callable, cast, ClassVar, Optional, overload, Sequence, TextIO, Type, Union +from typing import Any, Callable, cast, Optional, overload, Sequence, TextIO, Type, TYPE_CHECKING, Union import click from click.utils import make_str, PacifyFlushWrapper @@ -13,16 +14,16 @@ from rich_click.rich_help_formatter import RichHelpFormatter -class RichBaseCommand(click.BaseCommand): - """Richly formatted click BaseCommand. +class RichCommand(click.Command): + """Richly formatted click Command. - Inherits click.BaseCommand and overrides help and error methods + Inherits click.Command and overrides help and error methods to print richly formatted output. This class can be used as a mixin for other click command objects. """ - context_class: ClassVar[Type[RichContext]] = RichContext + context_class: Type[RichContext] = RichContext _formatter: Optional[RichHelpFormatter] = None @property @@ -68,9 +69,9 @@ def main( if args is None: if CLICK_IS_BEFORE_VERSION_8X: - from click.utils import get_os_args + from click.utils import get_os_args # type: ignore[attr-defined] - args = get_os_args() + args: Sequence[str] = get_os_args() # type: ignore[no-redef] else: args = sys.argv[1:] @@ -81,6 +82,9 @@ def main( else: args = list(args) + if TYPE_CHECKING: + assert args is not None + if prog_name is None: if CLICK_IS_BEFORE_VERSION_8X: prog_name = make_str(os.path.basename(sys.argv[0] if sys.argv else __file__)) @@ -91,7 +95,7 @@ def main( # Process shell completion requests and exit early. if CLICK_IS_BEFORE_VERSION_8X: - from click.core import _bashcomplete + from click.core import _bashcomplete # type: ignore[attr-defined] _bashcomplete(self, prog_name, complete_var) else: @@ -143,23 +147,18 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter): rich_format_help(self, ctx, formatter) -class RichCommand(RichBaseCommand, click.Command): - """Richly formatted click Command. - - Inherits click.Command and overrides help and error methods - to print richly formatted output. - """ - +with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning, module="click") -class RichMultiCommand(RichBaseCommand, click.MultiCommand): - """Richly formatted click MultiCommand. + class RichMultiCommand(RichCommand, click.MultiCommand): + """Richly formatted click MultiCommand. - Inherits click.MultiCommand and overrides help and error methods - to print richly formatted output. - """ + Inherits click.MultiCommand and overrides help and error methods + to print richly formatted output. + """ -class RichGroup(RichBaseCommand, click.Group): +class RichGroup(RichCommand, click.Group): """Richly formatted click Group. Inherits click.Group and overrides help and error methods diff --git a/src/rich_click/rich_context.py b/src/rich_click/rich_context.py index 0cc8f108..fd00548b 100644 --- a/src/rich_click/rich_context.py +++ b/src/rich_click/rich_context.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Optional, Type +from typing import Optional, Type import click from rich.console import Console @@ -10,7 +10,7 @@ class RichContext(click.Context): """Click Context class endowed with Rich superpowers.""" - formatter_class: ClassVar[Type[RichHelpFormatter]] = RichHelpFormatter + formatter_class: Type[RichHelpFormatter] = RichHelpFormatter def __init__( self, diff --git a/src/rich_click/rich_help_configuration.py b/src/rich_click/rich_help_configuration.py index 3b027a81..7f48e99d 100644 --- a/src/rich_click/rich_help_configuration.py +++ b/src/rich_click/rich_help_configuration.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from os import getenv -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union import rich.align import rich.color @@ -65,7 +65,7 @@ class RichHelpConfiguration: style_commands_table_box: rich.style.StyleType = field(default="") style_commands_table_row_styles: Optional[List[rich.style.StyleType]] = field(default=None) style_commands_table_border_style: Optional[rich.style.StyleType] = field(default=None) - style_commands_table_column_width_ratio: Optional[rich.style.StyleType] = field( + style_commands_table_column_width_ratio: Optional[Union[Tuple[None, None], Tuple[int, int]]] = field( default_factory=lambda: (None, None) ) style_errors_panel_border: rich.style.StyleType = field(default="red") diff --git a/tests/test_help.py b/tests/test_help.py index b79ee065..b6dafc0d 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -10,6 +10,7 @@ import rich_click.rich_click as rc from rich_click import command, rich_config, RichContext, RichHelpConfiguration from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X, CLICK_IS_VERSION_80 +from rich_click.decorators import pass_context from rich_click.rich_command import RichCommand try: @@ -219,6 +220,7 @@ def cli(): pass assert hasattr(cli, "__rich_context_settings__") is False + assert isinstance(cli, RichCommand) assert cli.console is not None assert cli.__doc__ is not None assert_str( @@ -281,7 +283,7 @@ def test_rich_config_context_settings(invoke: InvokeCli): @click.command( cls=RichCommand, context_settings={"rich_console": Console(), "rich_help_config": RichHelpConfiguration()} ) - @click.pass_context + @pass_context def cli(ctx: RichContext): assert isinstance(ctx, RichContext) assert ctx.console is not None From 71443d44ba2cb659928bc0463853f7666eb5ff29 Mon Sep 17 00:00:00 2001 From: dwreeves Date: Fri, 6 Oct 2023 16:40:05 -0400 Subject: [PATCH 04/11] get mypy working --- .pre-commit-config.yaml | 4 +- pyproject.toml | 2 - setup.py | 2 +- src/rich_click/__init__.py | 73 ++++++++- src/rich_click/_compat_click.py | 4 +- src/rich_click/cli.py | 4 +- src/rich_click/decorators.py | 153 ++++++++++++------ src/rich_click/rich_click.py | 223 ++++++++++++++------------ src/rich_click/rich_command.py | 37 +++-- src/rich_click/rich_context.py | 6 +- src/rich_click/rich_help_formatter.py | 2 +- tests/conftest.py | 74 ++++----- tests/fixtures/arguments.py | 2 +- tests/fixtures/context_settings.py | 2 +- tests/fixtures/custom_errors.py | 2 +- tests/fixtures/declarative.py | 7 +- tests/fixtures/envvar.py | 4 +- tests/fixtures/markdown.py | 2 +- tests/fixtures/metavars.py | 2 +- tests/fixtures/metavars_default.py | 4 +- tests/fixtures/rich_markup.py | 2 +- tests/fixtures/simple.py | 10 +- tests/fixtures/table_alignment.py | 17 +- tests/fixtures/table_styles.py | 2 +- tests/test_exit_code.py | 35 ++-- tests/test_help.py | 71 +++++--- 26 files changed, 461 insertions(+), 285 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 36bf3253..4e1ce06d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,7 +49,5 @@ repos: entry: mypy language: system types: [python] - exclude: ^examples/|^tests/fixtures/ + exclude: ^examples/ require_serial: true -# additional_dependencies: -# - mypy diff --git a/pyproject.toml b/pyproject.toml index 0b6c44d9..1c915cc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,10 +20,8 @@ profile = "black" [tool.mypy] python_version = "3.8" -ignore_missing_imports = "True" scripts_are_modules = "True" # strict = "True" -# follow_imports = "skip" [tool.pyright] include = ["src"] diff --git a/setup.py b/setup.py index 59e7c76c..677bf15d 100644 --- a/setup.py +++ b/setup.py @@ -3,6 +3,6 @@ setup( install_requires=["click>=7", "rich>=10.7.0", "importlib-metadata; python_version < '3.8'", "typing_extensions"], extras_require={ - "dev": ["pre-commit", "pytest", "flake8", "flake8-docstrings", "pytest-cov", "packaging"], + "dev": ["pre-commit", "pytest", "flake8", "flake8-docstrings", "pytest-cov", "packaging", "types-setuptools"], }, ) diff --git a/src/rich_click/__init__.py b/src/rich_click/__init__.py index 53da7276..217c49c7 100644 --- a/src/rich_click/__init__.py +++ b/src/rich_click/__init__.py @@ -8,13 +8,73 @@ __version__ = "1.7.0dev" -from click import * +# Import the entire click API here. +# We need to manually import these instead of `from click import *` to force mypy to recognize a few type annotation overrides for the rich_click decorators. +from click.core import Argument as Argument +from click.core import Command as Command +from click.core import CommandCollection as CommandCollection +from click.core import Context as Context +from click.core import Group as Group +from click.core import Option as Option +from click.core import Parameter as Parameter +from click.decorators import argument as argument +from click.decorators import confirmation_option as confirmation_option +from click.decorators import help_option as help_option +from click.decorators import make_pass_decorator as make_pass_decorator +from click.decorators import option as option +from click.decorators import pass_obj as pass_obj +from click.decorators import password_option as password_option +from click.decorators import version_option as version_option +from click.exceptions import Abort as Abort +from click.exceptions import BadArgumentUsage as BadArgumentUsage +from click.exceptions import BadOptionUsage as BadOptionUsage +from click.exceptions import BadParameter as BadParameter +from click.exceptions import ClickException as ClickException +from click.exceptions import FileError as FileError +from click.exceptions import MissingParameter as MissingParameter +from click.exceptions import NoSuchOption as NoSuchOption +from click.exceptions import UsageError as UsageError +from click.formatting import HelpFormatter as HelpFormatter +from click.formatting import wrap_text as wrap_text +from click.globals import get_current_context as get_current_context +from click.termui import clear as clear +from click.termui import confirm as confirm +from click.termui import echo_via_pager as echo_via_pager +from click.termui import edit as edit +from click.termui import getchar as getchar +from click.termui import launch as launch +from click.termui import pause as pause +from click.termui import progressbar as progressbar +from click.termui import prompt as prompt +from click.termui import secho as secho +from click.termui import style as style +from click.termui import unstyle as unstyle +from click.types import BOOL as BOOL +from click.types import Choice as Choice +from click.types import DateTime as DateTime +from click.types import File as File +from click.types import FLOAT as FLOAT +from click.types import FloatRange as FloatRange +from click.types import INT as INT +from click.types import IntRange as IntRange +from click.types import ParamType as ParamType +from click.types import Path as Path +from click.types import STRING as STRING +from click.types import Tuple as Tuple +from click.types import UNPROCESSED as UNPROCESSED +from click.types import UUID as UUID +from click.utils import echo as echo +from click.utils import format_filename as format_filename +from click.utils import get_app_dir as get_app_dir +from click.utils import get_binary_stream as get_binary_stream +from click.utils import get_text_stream as get_text_stream +from click.utils import open_file as open_file from . import rich_click as rich_click -from rich_click.decorators import command as command # type: ignore[no-redef] -from rich_click.decorators import group as group # type: ignore[no-redef] -from rich_click.decorators import pass_context as pass_context # type: ignore[no-redef,assignment] +from rich_click.decorators import command as command +from rich_click.decorators import group as group +from rich_click.decorators import pass_context as pass_context from rich_click.decorators import rich_config as rich_config from rich_click.rich_command import RichCommand as RichCommand from rich_click.rich_command import RichGroup as RichGroup @@ -41,8 +101,5 @@ def __getattr__(name: str) -> object: return RichMultiCommand # Support for potentially deprecated objects in newer versions of click: - elif name in {"BaseCommand", "OptionParser", "MultiCommand"}: - return getattr(click, name) - else: - raise AttributeError(name) + return getattr(click, name) diff --git a/src/rich_click/_compat_click.py b/src/rich_click/_compat_click.py index 374ea8cb..b5ca7794 100644 --- a/src/rich_click/_compat_click.py +++ b/src/rich_click/_compat_click.py @@ -1,8 +1,8 @@ try: - from importlib import metadata # type: ignore + from importlib import metadata # type: ignore[import,unused-ignore] except ImportError: # Python < 3.8 - import importlib_metadata as metadata # type: ignore + import importlib_metadata as metadata # type: ignore[no-redef,import] click_version = metadata.version("click") diff --git a/src/rich_click/cli.py b/src/rich_click/cli.py index 8941f0f6..b1a96397 100644 --- a/src/rich_click/cli.py +++ b/src/rich_click/cli.py @@ -6,10 +6,10 @@ from typing import Any, List, Optional try: - from importlib.metadata import entry_points + from importlib.metadata import entry_points # type: ignore[import,unused-ignore] except ImportError: # Support Python <3.8 - from importlib_metadata import entry_points # type: ignore[no-redef] + from importlib_metadata import entry_points # type: ignore[import,no-redef] import click from rich.console import Console diff --git a/src/rich_click/decorators.py b/src/rich_click/decorators.py index b36b894b..a0a6b7c8 100644 --- a/src/rich_click/decorators.py +++ b/src/rich_click/decorators.py @@ -7,11 +7,11 @@ __version__ = "1.7.0dev" -from typing import Any, Callable, cast, Optional, Type, TypeVar, Union +from typing import Any, Callable, cast, Optional, overload, Type, TYPE_CHECKING, TypeVar, Union from click import Command from click import command as click_command -from click import Context, Group +from click import Group from click import group as click_group from click import pass_context as click_pass_context from rich.console import Console @@ -28,62 +28,121 @@ # from click here. This way MyPy will recognize the import and not throw any errors. Furthermore, because of # the TYPE_CHECKING check, it does not influence the start routine at all. +_AnyCallable = Callable[..., Any] F = TypeVar("F", bound=Callable[..., Any]) -FC = TypeVar("FC", Command, Callable[..., Any]) +FC = TypeVar("FC", bound=Union[Command, _AnyCallable]) -def group(name: Optional[str] = None, cls: Optional[Type[Group]] = RichGroup, **attrs: Any) -> Callable[[F], Group]: +GrpType = TypeVar("GrpType", bound=Group) + + +# variant: no call, directly as decorator for a function. +@overload +def group(name: _AnyCallable) -> RichGroup: + ... + + +# variant: with positional name and with positional or keyword cls argument: +# @group(namearg, GroupCls, ...) or @group(namearg, cls=GroupCls, ...) +@overload +def group( + name: Optional[str], + cls: Type[GrpType], + **attrs: Any, +) -> Callable[[_AnyCallable], GrpType]: + ... + + +# variant: name omitted, cls _must_ be a keyword argument, @group(cmd=GroupCls, ...) +@overload +def group( + name: None = None, + *, + cls: Type[GrpType], + **attrs: Any, +) -> Callable[[_AnyCallable], GrpType]: + ... + + +# variant: with optional string name, no cls argument provided. +@overload +def group(name: Optional[str] = ..., cls: None = None, **attrs: Any) -> Callable[[_AnyCallable], RichGroup]: + ... + + +def group( + name: Union[str, _AnyCallable, None] = None, + cls: Optional[Type[GrpType]] = None, + **attrs: Any, +) -> Union[Group, Callable[[_AnyCallable], Union[RichGroup, GrpType]]]: """ Group decorator function. Defines the group() function so that it uses the RichGroup class by default. """ + if cls is None: + cls = cast(Type[GrpType], RichGroup) - def wrapper(fn): - if hasattr(fn, "__rich_context_settings__"): - rich_context_settings = getattr(fn, "__rich_context_settings__", {}) - console = rich_context_settings.get("rich_console", None) - help_config = rich_context_settings.get("help_config", None) - context_settings = attrs.get("context_settings", {}) - context_settings.update(rich_console=console, rich_help_config=help_config) - attrs.update(context_settings=context_settings) - del fn.__rich_context_settings__ - if callable(name) and cls: - group = click_group(cls=cls, **attrs)(name) - else: - group = click_group(name, cls=cls, **attrs) - cmd = cast(RichGroup, group(fn)) - return cmd + if callable(name): + return command(cls=cls, **attrs)(name) + + return command(name, cls, **attrs) + + +CmdType = TypeVar("CmdType", bound=Command) - return wrapper + +# variant: no call, directly as decorator for a function. +@overload +def command(name: _AnyCallable) -> RichCommand: + ... + + +# variant: with positional name and with positional or keyword cls argument: +# @command(namearg, CommandCls, ...) or @command(namearg, cls=CommandCls, ...) +@overload +def command( + name: Optional[str], + cls: Type[CmdType], + **attrs: Any, +) -> Callable[[_AnyCallable], CmdType]: + ... + + +# variant: name omitted, cls _must_ be a keyword argument, @command(cls=CommandCls, ...) +@overload +def command( + name: None = None, + *, + cls: Type[CmdType], + **attrs: Any, +) -> Callable[[_AnyCallable], CmdType]: + ... + + +# variant: with optional string name, no cls argument provided. +@overload +def command(name: Optional[str] = ..., cls: None = None, **attrs: Any) -> Callable[[_AnyCallable], RichCommand]: + ... def command( - name: Optional[str] = None, cls: Optional[Type[Command]] = RichCommand, **attrs: Any -) -> Callable[[F], Command]: + name: Union[Optional[str], _AnyCallable] = None, + cls: Optional[Type[CmdType]] = None, + **attrs: Any, +) -> Union[Command, Callable[[_AnyCallable], Union[RichCommand, CmdType]]]: """ Command decorator function. Defines the command() function so that it uses the RichCommand class by default. """ + if cls is None: + cls = cast(Type[CmdType], RichCommand) - def wrapper(fn): - if hasattr(fn, "__rich_context_settings__"): - rich_context_settings = getattr(fn, "__rich_context_settings__", {}) - console = rich_context_settings.get("rich_console", None) - help_config = rich_context_settings.get("help_config", None) - context_settings = attrs.get("context_settings", {}) - context_settings.update(rich_console=console, rich_help_config=help_config) - attrs.update(context_settings=context_settings) - del fn.__rich_context_settings__ - if callable(name) and cls: - command = click_command(cls=cls, **attrs)(name) - else: - command = click_command(name, cls=cls, **attrs) - cmd = cast(RichCommand, command(fn)) - return cmd + if callable(name): + return click_command(cls=cls, **attrs)(name) - return wrapper + return click_command(name, cls=cls, **attrs) class NotSupportedError(Exception): @@ -92,7 +151,9 @@ class NotSupportedError(Exception): pass -def rich_config(console: Optional[Console] = None, help_config: Optional[RichHelpConfiguration] = None): +def rich_config( + console: Optional[Console] = None, help_config: Optional[RichHelpConfiguration] = None +) -> Callable[[FC], FC]: """Use decorator to configure Rich Click settings. Args: @@ -103,7 +164,7 @@ def rich_config(console: Optional[Console] = None, help_config: Optional[RichHel """ if CLICK_IS_BEFORE_VERSION_8X: - def decorator_with_warning(obj): + def decorator_with_warning(obj: FC) -> FC: import warnings warnings.warn( @@ -122,8 +183,6 @@ def decorator(obj: FC) -> FC: setattr(obj, "__rich_context_settings__", {"rich_console": console, "rich_help_config": help_config}) else: raise NotSupportedError("`rich_config` requires a `RichCommand` or `RichGroup`. Try using the cls keyword") - - decorator.__doc__ = obj.__doc__ return obj return decorator @@ -140,11 +199,11 @@ def decorator(obj: FC) -> FC: P = ParamSpec("P") R = TypeVar("R") -C = TypeVar("C", bound=Context) -def pass_context(f: Callable[Concatenate[C, P], R]) -> Callable[P, R]: +def pass_context(f: Callable[Concatenate[RichContext, P], R]) -> Callable[P, R]: + # flake8: noqa: D400,D401 + """Marks a callback as wanting to receive the current context + object as first argument. + """ return click_pass_context(f) # type: ignore[arg-type] - - -pass_context.__doc__ = click_pass_context.__doc__ diff --git a/src/rich_click/rich_click.py b/src/rich_click/rich_click.py index 63c23a7c..99d0fbb7 100644 --- a/src/rich_click/rich_click.py +++ b/src/rich_click/rich_click.py @@ -1,7 +1,7 @@ import inspect import re from os import getenv -from typing import Dict, Iterable, List, Optional, TYPE_CHECKING, Union +from typing import Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union import click import rich.columns @@ -31,90 +31,94 @@ from rich.console import render_group as group # type: ignore[attr-defined,no-redef] # Default styles -STYLE_OPTION = "bold cyan" -STYLE_ARGUMENT = "bold cyan" -STYLE_COMMAND = "bold cyan" -STYLE_SWITCH = "bold green" -STYLE_METAVAR = "bold yellow" -STYLE_METAVAR_APPEND = "dim yellow" -STYLE_METAVAR_SEPARATOR = "dim" -STYLE_HEADER_TEXT = "" -STYLE_FOOTER_TEXT = "" -STYLE_USAGE = "yellow" -STYLE_USAGE_COMMAND = "bold" -STYLE_DEPRECATED = "red" -STYLE_HELPTEXT_FIRST_LINE = "" -STYLE_HELPTEXT = "dim" -STYLE_OPTION_HELP = "" -STYLE_OPTION_DEFAULT = "dim" -STYLE_OPTION_ENVVAR = "dim yellow" -STYLE_REQUIRED_SHORT = "red" -STYLE_REQUIRED_LONG = "dim red" -STYLE_OPTIONS_PANEL_BORDER = "dim" -ALIGN_OPTIONS_PANEL: Literal["left", "center", "right"] = "left" -STYLE_OPTIONS_TABLE_SHOW_LINES = False -STYLE_OPTIONS_TABLE_LEADING = 0 -STYLE_OPTIONS_TABLE_PAD_EDGE = False -STYLE_OPTIONS_TABLE_PADDING = (0, 1) -STYLE_OPTIONS_TABLE_BOX = "" -STYLE_OPTIONS_TABLE_ROW_STYLES = None -STYLE_OPTIONS_TABLE_BORDER_STYLE = None -STYLE_COMMANDS_PANEL_BORDER = "dim" -ALIGN_COMMANDS_PANEL: Literal["left", "center", "right"] = "left" -STYLE_COMMANDS_TABLE_SHOW_LINES = False -STYLE_COMMANDS_TABLE_LEADING = 0 -STYLE_COMMANDS_TABLE_PAD_EDGE = False -STYLE_COMMANDS_TABLE_PADDING = (0, 1) -STYLE_COMMANDS_TABLE_BOX = "" -STYLE_COMMANDS_TABLE_ROW_STYLES = None -STYLE_COMMANDS_TABLE_BORDER_STYLE = None -STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (None, None) -STYLE_ERRORS_PANEL_BORDER = "red" -ALIGN_ERRORS_PANEL: Literal["left", "center", "right"] = "left" -STYLE_ERRORS_SUGGESTION = "dim" -STYLE_ABORTED = "red" -WIDTH = int(getenv("TERMINAL_WIDTH")) if getenv("TERMINAL_WIDTH") else None # type: ignore -MAX_WIDTH = int(getenv("TERMINAL_WIDTH")) if getenv("TERMINAL_WIDTH") else WIDTH # type: ignore +STYLE_OPTION: rich.style.StyleType = "bold cyan" +STYLE_ARGUMENT: rich.style.StyleType = "bold cyan" +STYLE_COMMAND: rich.style.StyleType = "bold cyan" +STYLE_SWITCH: rich.style.StyleType = "bold green" +STYLE_METAVAR: rich.style.StyleType = "bold yellow" +STYLE_METAVAR_APPEND: rich.style.StyleType = "dim yellow" +STYLE_METAVAR_SEPARATOR: rich.style.StyleType = "dim" +STYLE_HEADER_TEXT: rich.style.StyleType = "" +STYLE_FOOTER_TEXT: rich.style.StyleType = "" +STYLE_USAGE: rich.style.StyleType = "yellow" +STYLE_USAGE_COMMAND: rich.style.StyleType = "bold" +STYLE_DEPRECATED: rich.style.StyleType = "red" +STYLE_HELPTEXT_FIRST_LINE: rich.style.StyleType = "" +STYLE_HELPTEXT: rich.style.StyleType = "dim" +STYLE_OPTION_HELP: rich.style.StyleType = "" +STYLE_OPTION_DEFAULT: rich.style.StyleType = "dim" +STYLE_OPTION_ENVVAR: rich.style.StyleType = "dim yellow" +STYLE_REQUIRED_SHORT: rich.style.StyleType = "red" +STYLE_REQUIRED_LONG: rich.style.StyleType = "dim red" +STYLE_OPTIONS_PANEL_BORDER: rich.style.StyleType = "dim" +ALIGN_OPTIONS_PANEL: rich.align.AlignMethod = "left" +STYLE_OPTIONS_TABLE_SHOW_LINES: bool = False +STYLE_OPTIONS_TABLE_LEADING: int = 0 +STYLE_OPTIONS_TABLE_PAD_EDGE: bool = False +STYLE_OPTIONS_TABLE_PADDING: rich.padding.PaddingDimensions = (0, 1) +STYLE_OPTIONS_TABLE_BOX: rich.style.StyleType = "" +STYLE_OPTIONS_TABLE_ROW_STYLES: Optional[List[rich.style.StyleType]] = None +STYLE_OPTIONS_TABLE_BORDER_STYLE: Optional[rich.style.StyleType] = None +STYLE_COMMANDS_PANEL_BORDER: rich.style.StyleType = "dim" +ALIGN_COMMANDS_PANEL: rich.align.AlignMethod = "left" +STYLE_COMMANDS_TABLE_SHOW_LINES: bool = False +STYLE_COMMANDS_TABLE_LEADING: int = 0 +STYLE_COMMANDS_TABLE_PAD_EDGE: bool = False +STYLE_COMMANDS_TABLE_PADDING: rich.padding.PaddingDimensions = (0, 1) +STYLE_COMMANDS_TABLE_BOX: rich.style.StyleType = "" +STYLE_COMMANDS_TABLE_ROW_STYLES: Optional[List[rich.style.StyleType]] = None +STYLE_COMMANDS_TABLE_BORDER_STYLE: Optional[rich.style.StyleType] = None +STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO: Optional[Union[Tuple[None, None], Tuple[int, int]]] = (None, None) +STYLE_ERRORS_PANEL_BORDER: rich.style.StyleType = "red" +ALIGN_ERRORS_PANEL: rich.align.AlignMethod = "left" +STYLE_ERRORS_SUGGESTION: rich.style.StyleType = "dim" +STYLE_ABORTED: rich.style.StyleType = "red" +WIDTH: Optional[int] = int(getenv("TERMINAL_WIDTH")) if getenv("TERMINAL_WIDTH") else None # type: ignore[arg-type] +MAX_WIDTH: Optional[int] = ( + int(getenv("TERMINAL_WIDTH")) if getenv("TERMINAL_WIDTH") else WIDTH # type: ignore[arg-type] +) COLOR_SYSTEM: Optional[ Literal["auto", "standard", "256", "truecolor", "windows"] ] = "auto" # Set to None to disable colors -FORCE_TERMINAL = True if getenv("GITHUB_ACTIONS") or getenv("FORCE_COLOR") or getenv("PY_COLORS") else None +FORCE_TERMINAL: Optional[bool] = ( + True if getenv("GITHUB_ACTIONS") or getenv("FORCE_COLOR") or getenv("PY_COLORS") else None +) # Fixed strings HEADER_TEXT: Optional[str] = None FOOTER_TEXT: Optional[str] = None -DEPRECATED_STRING = "(Deprecated) " -DEFAULT_STRING = "[default: {}]" -ENVVAR_STRING = "[env var: {}]" -REQUIRED_SHORT_STRING = "*" -REQUIRED_LONG_STRING = "[required]" -RANGE_STRING = " [{}]" -APPEND_METAVARS_HELP_STRING = "({})" -ARGUMENTS_PANEL_TITLE = "Arguments" -OPTIONS_PANEL_TITLE = "Options" -COMMANDS_PANEL_TITLE = "Commands" -ERRORS_PANEL_TITLE = "Error" +DEPRECATED_STRING: str = "(Deprecated) " +DEFAULT_STRING: str = "[default: {}]" +ENVVAR_STRING: str = "[env var: {}]" +REQUIRED_SHORT_STRING: str = "*" +REQUIRED_LONG_STRING: str = "[required]" +RANGE_STRING: str = " [{}]" +APPEND_METAVARS_HELP_STRING: str = "({})" +ARGUMENTS_PANEL_TITLE: str = "Arguments" +OPTIONS_PANEL_TITLE: str = "Options" +COMMANDS_PANEL_TITLE: str = "Commands" +ERRORS_PANEL_TITLE: str = "Error" ERRORS_SUGGESTION: Optional[str] = None # Default: Try 'cmd -h' for help. Set to False to disable. ERRORS_EPILOGUE: Optional[str] = None -ABORTED_TEXT = "Aborted." +ABORTED_TEXT: str = "Aborted." # Behaviours -SHOW_ARGUMENTS = False # Show positional arguments -SHOW_METAVARS_COLUMN = True # Show a column with the option metavar (eg. INTEGER) -APPEND_METAVARS_HELP = False # Append metavar (eg. [TEXT]) after the help text -GROUP_ARGUMENTS_OPTIONS = False # Show arguments with options instead of in own panel -OPTION_ENVVAR_FIRST = False # Show env vars before option help text instead of avert -USE_MARKDOWN = False # Parse help strings as markdown -USE_MARKDOWN_EMOJI = True # Parse emoji codes in markdown :smile: -USE_RICH_MARKUP = False # Parse help strings for rich markup (eg. [red]my text[/]) +SHOW_ARGUMENTS: bool = False # Show positional arguments +SHOW_METAVARS_COLUMN: bool = True # Show a column with the option metavar (eg. INTEGER) +APPEND_METAVARS_HELP: bool = False # Append metavar (eg. [TEXT]) after the help text +GROUP_ARGUMENTS_OPTIONS: bool = False # Show arguments with options instead of in own panel +OPTION_ENVVAR_FIRST: bool = False # Show env vars before option help text instead of avert +USE_MARKDOWN: bool = False # Parse help strings as markdown +USE_MARKDOWN_EMOJI: bool = True # Parse emoji codes in markdown :smile: +USE_RICH_MARKUP: bool = False # Parse help strings for rich markup (eg. [red]my text[/]) # Define sorted groups of panels to display subcommands COMMAND_GROUPS: Dict[str, List[Dict[str, Union[str, List[str]]]]] = {} # Define sorted groups of panels to display options and arguments OPTION_GROUPS: Dict[str, List[Dict[str, Union[str, List[str], Dict[str, List[str]]]]]] = {} -USE_CLICK_SHORT_HELP = False # Use click's default function to truncate help text +USE_CLICK_SHORT_HELP: bool = False # Use click's default function to truncate help text -highlighter = OptionHighlighter() -_formatter = None +highlighter: rich.highlighter.Highlighter = OptionHighlighter() +_formatter: Optional[RichHelpFormatter] = None def _get_rich_formatter(formatter: Optional[click.HelpFormatter] = None) -> RichHelpFormatter: @@ -235,8 +239,8 @@ def _get_help_text( yield _make_rich_rext(remaining_lines, config.style_helptext, formatter) -def _get_parameter_help( - param: Union[click.Option, click.Argument], ctx: click.Context, formatter: Optional[RichHelpFormatter] = None +def _get_option_help( + param: Union[click.Argument, click.Option], ctx: click.Context, formatter: Optional[RichHelpFormatter] = None ) -> rich.columns.Columns: """Build primary help text for a click option or argument. @@ -245,7 +249,7 @@ def _get_parameter_help( Additional elements are appended to show the default and required status if applicable. Args: - param (click.Option or click.Argument): Option or argument to build help text for + param (click.Argument or click.Option): Parameter to build help text for ctx (click.Context): Click Context object Returns: @@ -255,6 +259,9 @@ def _get_parameter_help( config = formatter.config items: List[rich.console.RenderableType] = [] + if TYPE_CHECKING: + assert isinstance(param.name, str) + # Get the environment variable first envvar = getattr(param, "envvar", None) # https://github.com/pallets/click/blob/0aec1168ac591e159baf6f61026d6ae322c53aaf/src/click/core.py#L2720-L2726 @@ -275,6 +282,7 @@ def _get_parameter_help( # Main help text if getattr(param, "help", None): if TYPE_CHECKING: + assert isinstance(param, click.Option) assert hasattr(param, "help") assert isinstance(param.help, str) paragraphs = param.help.split("\n\n") @@ -309,25 +317,31 @@ def _get_parameter_help( # Default value # Click 7.x, 8.0, and 8.1 all behave slightly differently when handling the default value help text. - if CLICK_IS_BEFORE_VERSION_8X: - parse_default = param.default is not None and (param.show_default or getattr(ctx, "show_default", None)) + if not hasattr(param, "show_default"): + parse_default = False + elif CLICK_IS_BEFORE_VERSION_8X: + parse_default = bool(param.default is not None and (param.show_default or getattr(ctx, "show_default", None))) elif CLICK_IS_VERSION_80: - show_default_is_str = isinstance(getattr(param, "show_default", None), str) - parse_default = show_default_is_str or (param.default is not None and (param.show_default or ctx.show_default)) + show_default_is_str = isinstance(param.show_default, str) + parse_default = bool( + show_default_is_str or (param.default is not None and (param.show_default or ctx.show_default)) + ) else: - show_default = False show_default_is_str = False - if getattr(param, "show_default", None) is not None: + if param.show_default is not None: if isinstance(param.show_default, str): show_default_is_str = show_default = True else: - show_default = param.show_default + show_default = bool(param.show_default) else: - show_default = getattr(ctx, "show_default", False) + show_default = bool(getattr(ctx, "show_default", False)) parse_default = bool(show_default_is_str or (show_default and (param.default is not None))) if parse_default: - default_str_match = re.search(r"\[(?:.+; )?default: (.*)\]", param.get_help_record(ctx)[-1]) + help_record = param.get_help_record(ctx) + if TYPE_CHECKING: + assert isinstance(help_record, tuple) + default_str_match = re.search(r"\[(?:.+; )?default: (.*)\]", help_record[-1]) if default_str_match: # Don't show the required string, as we show that afterwards anyway default_str = default_str_match.group(1).replace("; required", "") @@ -381,7 +395,7 @@ def get_rich_usage( obj: Union[click.Command, click.Group], ctx: click.Context, formatter: click.HelpFormatter, -): +) -> None: """Get usage text for a command.""" formatter = _get_rich_formatter(formatter) config = formatter.config @@ -411,7 +425,7 @@ class UsageHighlighter(RegexHighlighter): def rich_format_help( - obj: click.BaseCommand, + obj: click.Command, ctx: click.Context, formatter: click.HelpFormatter, ) -> None: @@ -481,13 +495,15 @@ def rich_format_help( if isinstance(param, click.core.Argument) and not config.group_arguments_options: argument_group_options.append(param.opts[0]) else: - list_of_option_groups: List = option_groups[-1]["options"] # type: ignore + list_of_option_groups: List = option_groups[-1]["options"] # type: ignore[assignment] list_of_option_groups.append(param.opts[0]) # If we're not grouping arguments and we got some, prepend before default options if len(argument_group_options) > 0: extra_option_group = {"name": config.arguments_panel_title, "options": argument_group_options} - option_groups.insert(len(option_groups) - 1, extra_option_group) # type: ignore + option_groups.insert(len(option_groups) - 1, extra_option_group) + + # print("!", option_groups) # Print each option group panel for option_group in option_groups: @@ -522,12 +538,18 @@ def rich_format_help( metavar = Text(style=config.style_metavar, overflow="fold") metavar_str = param.make_metavar() + if TYPE_CHECKING: + assert isinstance(param.name, str) + assert isinstance(param, click.Option) + # Do it ourselves if this is a positional argument if isinstance(param, click.core.Argument) and re.match(rf"\[?{param.name.upper()}]?", metavar_str): metavar_str = param.type.name.upper() # Attach metavar if param is a positional argument, or if it is a non boolean and non flag option - if isinstance(param, click.core.Argument) or (metavar_str != "BOOLEAN" and not param.is_flag): + if isinstance(param, click.core.Argument) or ( + metavar_str != "BOOLEAN" and not getattr(param, "is_flag", None) + ): metavar.append(metavar_str) # Range - from @@ -545,7 +567,7 @@ def rich_format_help( pass # Required asterisk - required = "" + required: Union[Text, str] = "" if param.required: required = Text(config.required_short_string, style=config.style_required_short) @@ -564,7 +586,7 @@ class MetavarHighlighter(RegexHighlighter): highlighter(highlighter(",".join(opt_long_strs))), highlighter(highlighter(",".join(opt_short_strs))), metavar_highlighter(metavar), - _get_parameter_help(param, ctx, formatter), + _get_option_help(param, ctx, formatter), ] # Remove metavar if specified in config @@ -583,8 +605,8 @@ class MetavarHighlighter(RegexHighlighter): "pad_edge": config.style_options_table_pad_edge, "padding": config.style_options_table_padding, } - t_styles.update(option_group.get("table_styles", {})) # type: ignore - box_style = getattr(box, t_styles.pop("box"), None) # type: ignore + t_styles.update(option_group.get("table_styles", {})) # type: ignore[arg-type] + box_style = getattr(box, t_styles.pop("box"), None) # type: ignore[arg-type] options_table = Table( highlight=True, @@ -621,7 +643,7 @@ class MetavarHighlighter(RegexHighlighter): if command in cmd_group.get("commands", []): break else: - commands: List = cmd_groups[-1]["commands"] # type: ignore + commands: List = cmd_groups[-1]["commands"] # type: ignore[assignment] commands.append(command) # Print each command group panel @@ -635,8 +657,8 @@ class MetavarHighlighter(RegexHighlighter): "pad_edge": config.style_commands_table_pad_edge, "padding": config.style_commands_table_padding, } - t_styles.update(cmd_group.get("table_styles", {})) # type: ignore - box_style = getattr(box, t_styles.pop("box"), None) # type: ignore + t_styles.update(cmd_group.get("table_styles", {})) # type: ignore[arg-type] + box_style = getattr(box, t_styles.pop("box"), None) # type: ignore[arg-type] commands_table = Table( highlight=False, @@ -647,20 +669,23 @@ class MetavarHighlighter(RegexHighlighter): ) # Define formatting in first column, as commands don't match highlighter regex # and set column ratio for first and second column, if a ratio has been set - commands_table.add_column( - style="bold cyan", - no_wrap=True, - ratio=STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO[0], - ) + if config.style_commands_table_column_width_ratio is None: + table_column_width_ratio: Union[Tuple[None, None], Tuple[int, int]] = (None, None) + else: + table_column_width_ratio = config.style_commands_table_column_width_ratio + + commands_table.add_column(style="bold cyan", no_wrap=True, ratio=table_column_width_ratio[0]) commands_table.add_column( no_wrap=False, - ratio=STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO[1], + ratio=table_column_width_ratio[1], ) for command in cmd_group.get("commands", []): # Skip if command does not exist if command not in obj.list_commands(ctx): continue cmd = obj.get_command(ctx, command) + if TYPE_CHECKING: + assert cmd is not None if cmd.hidden: continue # Use the truncated short text as with vanilla text if requested diff --git a/src/rich_click/rich_command.py b/src/rich_click/rich_command.py index 95fe13a8..5caf6bd0 100644 --- a/src/rich_click/rich_command.py +++ b/src/rich_click/rich_command.py @@ -26,6 +26,16 @@ class RichCommand(click.Command): context_class: Type[RichContext] = RichContext _formatter: Optional[RichHelpFormatter] = None + @wraps(click.Command.__init__) + def __init__(self, *args: Any, **kwargs: Any): + """Create Rich Command instance.""" + super().__init__(*args, **kwargs) + if self.callback is not None: + if hasattr(self.callback, "__rich_context_settings__"): + rich_context_settings = getattr(self.callback, "__rich_context_settings__", {}) + self.context_settings.update(rich_context_settings) + del self.callback.__rich_context_settings__ + @property def console(self): """Rich Console. @@ -54,7 +64,6 @@ def formatter(self) -> RichHelpFormatter: self._formatter = RichHelpFormatter(config=self.help_config) return self._formatter - @wraps(click.BaseCommand.main) def main( self, args: Optional[Sequence[str]] = None, @@ -168,16 +177,20 @@ class RichGroup(RichCommand, click.Group): command_class: Type[RichCommand] = RichCommand group_class = type - @overload - def command(self, __func: Callable[..., Any]) -> click.Command: - ... + if CLICK_IS_BEFORE_VERSION_8X: + + @overload + def command(self, __func: Callable[..., Any]) -> click.Command: + ... - @overload - def command(self, *args: Any, **kwargs: Any) -> Callable[[Callable[..., Any]], click.Command]: - ... + @overload + def command(self, *args: Any, **kwargs: Any) -> Callable[[Callable[..., Any]], click.Command]: + ... - def command(self, *args: Any, **kwargs: Any) -> Union[Callable[[Callable[..., Any]], click.Command], click.Command]: - # This method override is required for Click 7.x compatibility. - # (The command_class ClassVar was not added until 8.0.) - kwargs.setdefault("cls", self.command_class) - return super().command(*args, **kwargs) + def command( + self, *args: Any, **kwargs: Any + ) -> Union[Callable[[Callable[..., Any]], click.Command], click.Command]: + # This method override is required for Click 7.x compatibility. + # (The command_class ClassVar was not added until 8.0.) + kwargs.setdefault("cls", self.command_class) + return super().command(*args, **kwargs) diff --git a/src/rich_click/rich_context.py b/src/rich_click/rich_context.py index fd00548b..f54b5fdb 100644 --- a/src/rich_click/rich_context.py +++ b/src/rich_click/rich_context.py @@ -1,4 +1,4 @@ -from typing import Optional, Type +from typing import Any, Optional, Type import click from rich.console import Console @@ -14,10 +14,10 @@ class RichContext(click.Context): def __init__( self, - *args, + *args: Any, rich_console: Optional[Console] = None, rich_help_config: Optional[RichHelpConfiguration] = None, - **kwargs, + **kwargs: Any, ) -> None: """Create Rich Context instance. diff --git a/src/rich_click/rich_help_formatter.py b/src/rich_click/rich_help_formatter.py index fe8afe25..3e5cca88 100644 --- a/src/rich_click/rich_help_formatter.py +++ b/src/rich_click/rich_help_formatter.py @@ -66,7 +66,7 @@ def get_module_config() -> RichHelpConfiguration: A function-level import is used to avoid a circular dependency between the formatter and formatter operations. """ - from rich_click.rich_click import get_module_help_configuration # type: ignore + from rich_click.rich_click import get_module_help_configuration return get_module_help_configuration() diff --git a/tests/conftest.py b/tests/conftest.py index 13ae3073..ed937444 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,22 +21,22 @@ @pytest.fixture -def root_dir(): +def root_dir() -> Path: return Path(__file__).parent @pytest.fixture -def fixtures_dir(): +def fixtures_dir() -> Path: return Path(__file__).parent / "fixtures" @pytest.fixture -def tmpdir(root_dir: Path): +def tmpdir(root_dir: Path) -> Path: return root_dir / "tmp" @pytest.fixture -def expectations_dir(root_dir: Path): +def expectations_dir(root_dir: Path) -> Path: return root_dir / "expectations" @@ -45,8 +45,8 @@ def click_major_version() -> int: return int(click.__version__.split(".")[0]) -class AssertStr: - def __call__(self, actual: str, expectation: Union[str, Path]): +class AssertStr(Protocol): + def __call__(self, actual: str, expectation: Union[str, Path]) -> None: """Assert strings by normalizining line endings Args: @@ -57,8 +57,8 @@ def __call__(self, actual: str, expectation: Union[str, Path]): @pytest.fixture -def assert_str(request: pytest.FixtureRequest, tmpdir: Path): - def assertion(actual: str, expectation: Union[str, Path]): +def assert_str(request: pytest.FixtureRequest, tmpdir: Path) -> Callable[[str, Union[str, Path]], None]: + def assertion(actual: str, expectation: Union[str, Path]) -> None: if isinstance(expectation, Path): if expectation.exists(): expected = expectation.read_text() @@ -81,7 +81,7 @@ def assertion(actual: str, expectation: Union[str, Path]): class AssertDicts(Protocol): - def __call__(self, actual: Dict[str, Any], expectation: Union[Path, Dict[str, Any]]): + def __call__(self, actual: Dict[str, Any], expectation: Union[Path, Dict[str, Any]]) -> None: """Assert two dictionaries by normalizing as json Args: @@ -92,17 +92,17 @@ def __call__(self, actual: Dict[str, Any], expectation: Union[Path, Dict[str, An @pytest.fixture -def assert_dicts(request: pytest.FixtureRequest, tmpdir: Path): +def assert_dicts(request: pytest.FixtureRequest, tmpdir: Path) -> AssertDicts: def load_obj(s: str) -> Any: return json.loads(s) def dump_obj(obj: Any) -> str: return json.dumps(obj, indent=4) - def roundtrip(obj): + def roundtrip(obj: Any) -> Any: return load_obj(dump_obj(obj)) - def assertion(actual: Dict[str, Any], expectation: Union[Path, Dict[str, Any]]): + def assertion(actual: Dict[str, Any], expectation: Union[Path, Dict[str, Any]]) -> None: if isinstance(expectation, Path): if expectation.exists(): expected = load_obj(expectation.read_text()) @@ -128,7 +128,7 @@ def assertion(actual: Dict[str, Any], expectation: Union[Path, Dict[str, Any]]): @pytest.fixture(autouse=True) -def initialize_rich_click(): +def initialize_rich_click() -> None: """Initialize `rich_click` module.""" # to isolate module-level configuration we # must reload the rich_click module between @@ -147,8 +147,12 @@ def initialize_rich_click(): rc.FORCE_TERMINAL = True +class CommandModuleType(ModuleType): + cli: RichCommand + + class LoadCommandModule(Protocol): - def __call__(self, namespace: str) -> ModuleType: + def __call__(self, namespace: str) -> CommandModuleType: """Dynamically loads a rich cli fixture. Args: @@ -171,21 +175,17 @@ def replace_link_ids(render: str) -> str: @pytest.fixture -def load_command(): - def load(namespace: str): - # set fixed terminal width for all commands - if namespace: - # reload the cli module to reset state - # for multiple tests of the same cli command - module = importlib.import_module(namespace) - reload(module) - return module +def load_command() -> LoadCommandModule: + def load(namespace: str) -> CommandModuleType: + module = importlib.import_module(namespace) + reload(module) + return cast(CommandModuleType, module) return load class InvokeCli(Protocol): - def __call__(self, cmd: click.BaseCommand, *args: str) -> Result: + def __call__(self, cmd: click.Command, *args: Any, **kwargs: Any) -> Result: """Invoke click command. Small convenience fixture to allow invoking a click Command @@ -198,10 +198,10 @@ def __call__(self, cmd: click.BaseCommand, *args: str) -> Result: @pytest.fixture -def invoke(): +def invoke() -> InvokeCli: runner = CliRunner() - def invoke(cmd, *args, **kwargs): + def invoke(cmd: click.Command, *args: Any, **kwargs: Any) -> Result: result = runner.invoke(cmd, *args, **kwargs, standalone_mode=False) return result @@ -215,7 +215,7 @@ def __call__( args: str, error: Optional[Type[Exception]], rich_config: Optional[Callable[[Any], Union[RichGroup, RichCommand]]], - ): + ) -> None: """Invokes the cli command and applies assertions against the results This command resolves the cli application from the fixtures directory dynamically @@ -246,24 +246,24 @@ def assert_rich_format( request: pytest.FixtureRequest, expectations_dir: Path, invoke: InvokeCli, - load_command, - assert_dicts, - assert_str, - click_major_version, -): - def config_to_dict(config: RichHelpConfiguration): + load_command: LoadCommandModule, + assert_dicts: AssertDicts, + assert_str: AssertStr, + click_major_version: int, +) -> AssertRichFormat: + def config_to_dict(config: RichHelpConfiguration) -> Dict[Any, Any]: config_dict = asdict(config) config_dict["highlighter"] = cast(OptionHighlighter, config.highlighter).highlights return config_dict def assertion( - cmd: Union[str, Union[RichCommand, RichGroup]], + cmd: Union[str, RichCommand], args: str, error: Optional[Type[Exception]], - rich_config: Optional[Callable[[Any], Union[RichGroup, RichCommand]]], - ): + rich_config: Optional[Callable[[Any], RichCommand]], + ) -> None: if isinstance(cmd, str): - command: Union[RichCommand, RichGroup] = load_command(f"fixtures.{cmd}").cli + command = load_command(f"fixtures.{cmd}").cli else: command = cmd diff --git a/tests/fixtures/arguments.py b/tests/fixtures/arguments.py index f89bf974..30f3549f 100644 --- a/tests/fixtures/arguments.py +++ b/tests/fixtures/arguments.py @@ -16,7 +16,7 @@ ) @click.option("--all", is_flag=True, help="Sync all the things?") @click.option("--debug", is_flag=True, help="Enable debug mode") -def cli(input, type, all, debug): +def cli(input: str, type: str, all: bool, debug: bool) -> None: """ My amazing tool does all the things. diff --git a/tests/fixtures/context_settings.py b/tests/fixtures/context_settings.py index 509988e5..0b3d6fcd 100644 --- a/tests/fixtures/context_settings.py +++ b/tests/fixtures/context_settings.py @@ -10,7 +10,7 @@ show_default="show me in c8+", help="Show 'default: (show me in c8+)' in click>=8.0. In click 7, no default is shown.", ) -def cli(a, b, c, d): +def cli(a: str, b: str, c: str, d: str): """ Test cases for context_settings. diff --git a/tests/fixtures/custom_errors.py b/tests/fixtures/custom_errors.py index d277cbf2..f15a6be1 100644 --- a/tests/fixtures/custom_errors.py +++ b/tests/fixtures/custom_errors.py @@ -18,7 +18,7 @@ ) @click.option("--all", is_flag=True, help="Sync all the things?") @click.option("--debug", is_flag=True, default=False, help="Enable debug mode") -def cli(input, type, all, debug): +def cli(input, type, all, debug) -> None: """ My amazing tool does all the things. diff --git a/tests/fixtures/declarative.py b/tests/fixtures/declarative.py index c4926675..5b09f7a0 100644 --- a/tests/fixtures/declarative.py +++ b/tests/fixtures/declarative.py @@ -1,11 +1,11 @@ import click -from rich_click import RichCommand, RichGroup +from rich_click import pass_context, RichCommand, RichContext, RichGroup @click.group(cls=RichGroup) @click.option("--debug/--no-debug", default=False) -def cli(debug): +def cli(debug) -> None: """ My amazing tool does all the things. @@ -19,7 +19,8 @@ def cli(debug): @cli.command(cls=RichCommand) -def sync(): +@pass_context +def sync(ctx: RichContext) -> None: """Synchronise all your files between two places.""" click.echo("Syncing") diff --git a/tests/fixtures/envvar.py b/tests/fixtures/envvar.py index fecf63f8..359ac892 100644 --- a/tests/fixtures/envvar.py +++ b/tests/fixtures/envvar.py @@ -8,7 +8,7 @@ @click.group(context_settings=dict(auto_envvar_prefix="GREETER")) @click.option("--debug/--no-debug") -def cli(debug): +def cli(debug) -> None: click.echo(f"Debug mode is {'on' if debug else 'off'}") @@ -29,7 +29,7 @@ def cli(debug): show_default=True, help="This can be set via env var EMAIL or EMAIL_ADDRESS", ) -def greet(username, nickname, email): +def greet(username, nickname, email) -> None: click.echo(f"Hello {username} ({nickname}) with email {email}!") diff --git a/tests/fixtures/markdown.py b/tests/fixtures/markdown.py index cafa2ded..434550ce 100644 --- a/tests/fixtures/markdown.py +++ b/tests/fixtures/markdown.py @@ -18,7 +18,7 @@ ) @click.option("--all", is_flag=True, help="Sync\n 1. all\n 2. the\n 3. things?") @click.option("--debug", is_flag=True, help="# Enable `debug mode`") -def cli(input, type, all, debug): +def cli(input, type, all, debug) -> None: """ My amazing tool does _**all the things**_. diff --git a/tests/fixtures/metavars.py b/tests/fixtures/metavars.py index 4e5f46e3..acead524 100644 --- a/tests/fixtures/metavars.py +++ b/tests/fixtures/metavars.py @@ -45,7 +45,7 @@ show_default=True, help="This click choice has loads of options.", ) -def cli(debug, number): +def cli(debug, number) -> None: """ My amazing tool does all the things. diff --git a/tests/fixtures/metavars_default.py b/tests/fixtures/metavars_default.py index f037ee48..267cda36 100644 --- a/tests/fixtures/metavars_default.py +++ b/tests/fixtures/metavars_default.py @@ -1,7 +1,7 @@ import rich_click as click -@click.command() +@click.command("cli") @click.option("--debug", is_flag=True, help="Enable debug mode.") @click.option( "--number", @@ -42,7 +42,7 @@ show_default=True, help="This click choice has loads of options.", ) -def cli(debug, number): +def cli(debug, number) -> None: """ My amazing tool does all the things. diff --git a/tests/fixtures/rich_markup.py b/tests/fixtures/rich_markup.py index b8b8f54b..ac03a22b 100644 --- a/tests/fixtures/rich_markup.py +++ b/tests/fixtures/rich_markup.py @@ -4,7 +4,7 @@ click.rich_click.USE_RICH_MARKUP = True -@click.command() +@click.command @click.option( "--input", type=click.Path(), diff --git a/tests/fixtures/simple.py b/tests/fixtures/simple.py index 9792ff98..cca1d7b4 100644 --- a/tests/fixtures/simple.py +++ b/tests/fixtures/simple.py @@ -1,7 +1,7 @@ import rich_click as click -@click.group() +@click.group @click.option( "--debug/--no-debug", "-d/-n", @@ -11,7 +11,7 @@ Double newlines are preserved.""", ) -def cli(debug): +def cli(debug) -> None: """ My amazing tool does all the things. @@ -24,7 +24,7 @@ def cli(debug): print(f"Debug mode is {'on' if debug else 'off'}") -@cli.command() +@cli.command @click.option( "--type", required=True, @@ -33,7 +33,7 @@ def cli(debug): help="Type of file to sync", ) @click.option("--all", is_flag=True) -def sync(type, all): +def sync(type, all) -> None: """Synchronise all your files between two places. Example command that doesn't do much except print to the terminal.""" print("Syncing") @@ -41,7 +41,7 @@ def sync(type, all): @cli.command(short_help="Optionally use short-help for the group help text") @click.option("--all", is_flag=True, help="Get everything") -def download(all): +def download(all) -> None: """ Pretend to download some files from somewhere. Multi-line help strings are unwrapped diff --git a/tests/fixtures/table_alignment.py b/tests/fixtures/table_alignment.py index d2d2b722..252ae883 100644 --- a/tests/fixtures/table_alignment.py +++ b/tests/fixtures/table_alignment.py @@ -58,7 +58,7 @@ help="Show the debug log messages", ) @click.version_option("1.23", prog_name="mytool") -def cli(type, debug): +def cli(type, debug) -> None: """ My amazing tool does all the things. @@ -76,26 +76,29 @@ def cli(type, debug): @click.option("--output", "-o", help="Output path") @click.option("--all", is_flag=True, help="Sync all the things?") @click.option("--overwrite", is_flag=True, help="Overwrite local files") -def sync(input, output, all, overwrite): +def sync(input, output, all, overwrite) -> None: """Synchronise all your files between two places.""" print("Syncing") +# We vary the typing for cli.command() function a bit to test the function signature overloading + + @cli.command() @click.option("--all", is_flag=True, help="Get everything") -def download(all): +def download(all) -> None: """Pretend to download some files from somewhere.""" print("Downloading") -@cli.command() -def auth(): +@cli.command("auth") +def auth() -> None: """Authenticate the app.""" print("Downloading") -@cli.command() -def config(): +@cli.command +def config() -> None: """Set up the configuration.""" print("Downloading") diff --git a/tests/fixtures/table_styles.py b/tests/fixtures/table_styles.py index 8c96c040..99ead0e1 100644 --- a/tests/fixtures/table_styles.py +++ b/tests/fixtures/table_styles.py @@ -10,7 +10,7 @@ click.rich_click.STYLE_COMMANDS_TABLE_ROW_STYLES = ["magenta", "yellow", "cyan", "green"] -@click.group() +@click.group("cli") @click.option( "--type", default="files", diff --git a/tests/test_exit_code.py b/tests/test_exit_code.py index 1ce319e9..7ba57fe2 100644 --- a/tests/test_exit_code.py +++ b/tests/test_exit_code.py @@ -1,5 +1,6 @@ import sys +import click import pytest from click.testing import CliRunner @@ -9,12 +10,12 @@ # Don't use the 'invoke' fixture because we want control over the standalone_mode kwarg. -def test_command_exit_code_with_context(): +def test_command_exit_code_with_context() -> None: for expected_exit_code in range(10): @command("cli") @pass_context - def cli(ctx: RichContext): + def cli(ctx: RichContext) -> None: ctx.exit(expected_exit_code) runner = CliRunner() @@ -40,11 +41,11 @@ def subcommand(ctx: RichContext): assert res.exit_code == expected_exit_code -def test_command_exit_code_with_sys_exit(): +def test_command_exit_code_with_sys_exit() -> None: for expected_exit_code in range(10): @command("cli") - def cli(): + def cli() -> None: sys.exit(expected_exit_code) runner = CliRunner() @@ -52,15 +53,15 @@ def cli(): assert res.exit_code == expected_exit_code -def test_group_exit_code_with_sys_exit(): +def test_group_exit_code_with_sys_exit() -> None: for expected_exit_code in range(10): @group("cli") - def cli(): + def cli() -> None: sys.exit(expected_exit_code) @cli.command("subcommand") - def subcommand(): + def subcommand() -> None: sys.exit(999) runner = CliRunner() @@ -68,9 +69,9 @@ def subcommand(): assert res.exit_code == expected_exit_code -def test_command_return_value_does_not_raise_exit_code(): +def test_command_return_value_does_not_raise_exit_code() -> None: @command("cli") - def cli(): + def cli() -> int: return 5 runner = CliRunner() @@ -78,13 +79,13 @@ def cli(): assert res.exit_code == 0 -def test_group_return_value_does_not_raise_exit_code(): +def test_group_return_value_does_not_raise_exit_code() -> None: @group("cli") - def cli(): + def cli() -> int: return 5 @cli.command("subcommand") - def subcommand(): + def subcommand() -> int: return 10 runner = CliRunner() @@ -93,12 +94,12 @@ def subcommand(): @pytest.mark.skipif(CLICK_IS_BEFORE_VERSION_8X, reason="Result does not have return_value attribute.") -def test_command_return_value_is_exit_code_when_not_standalone(): +def test_command_return_value_is_exit_code_when_not_standalone() -> None: for expected_exit_code in range(10): @command("cli") @pass_context - def cli(ctx: RichContext): + def cli(ctx: click.Context) -> None: ctx.exit(expected_exit_code) runner = CliRunner() @@ -107,17 +108,17 @@ def cli(ctx: RichContext): @pytest.mark.skipif(CLICK_IS_BEFORE_VERSION_8X, reason="Result does not have return_value attribute.") -def test_group_return_value_is_exit_code_when_not_standalone(): +def test_group_return_value_is_exit_code_when_not_standalone() -> None: for expected_exit_code in range(10): @group("cli") @pass_context - def cli(ctx: RichContext): + def cli(ctx: RichContext) -> None: ctx.exit(expected_exit_code) @cli.command("subcommand") @pass_context - def subcommand(ctx: RichContext): + def subcommand(ctx: RichContext) -> None: # I should not run ctx.exit(0) diff --git a/tests/test_help.py b/tests/test_help.py index b6dafc0d..44cf9c37 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -1,4 +1,4 @@ -from typing import Optional, Type +from typing import Any, Callable, Optional, Type import click import pytest @@ -8,16 +8,15 @@ from rich.console import Console import rich_click.rich_click as rc -from rich_click import command, rich_config, RichContext, RichHelpConfiguration +from rich_click import command, pass_context, rich_config, RichContext, RichHelpConfiguration from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X, CLICK_IS_VERSION_80 -from rich_click.decorators import pass_context -from rich_click.rich_command import RichCommand +from rich_click.rich_command import RichCommand, RichGroup try: - from importlib import metadata # type: ignore + from importlib import metadata # type: ignore[import,unused-ignore] except ImportError: # Python < 3.8 - import importlib_metadata as metadata # type: ignore + import importlib_metadata as metadata # type: ignore[no-redef,import] rich_version = version.parse(metadata.version("rich")) @@ -204,15 +203,35 @@ @pytest.mark.filterwarnings("ignore:^.*click prior to.*$:RuntimeWarning") def test_rich_click( cmd: str, args: str, error: Optional[Type[Exception]], rich_config, assert_rich_format: AssertRichFormat -): +) -> None: assert_rich_format(cmd, args, error, rich_config) +class ClickGroupWithRichCommandClass(click.Group): + command_class = RichCommand + + @pytest.mark.skipif(CLICK_IS_BEFORE_VERSION_8X, reason="rich_config not supported prior to click v8") -def test_rich_config_decorator_order(invoke: InvokeCli, assert_str: AssertStr): - @command() - @rich_config(Console(), RichHelpConfiguration(max_width=80, use_markdown=True)) - def cli(): +@pytest.mark.parametrize( + "command_callable", + [ + pytest.param(lambda: command, id="1"), + pytest.param(lambda: command(), id="2"), + pytest.param(lambda: command("cli"), id="3"), + pytest.param(lambda: click.command(cls=RichCommand), id="4"), + pytest.param(lambda: click.command("cli", cls=RichCommand), id="5"), + pytest.param(lambda: RichGroup(name="grp", callback=lambda: None).command, id="6"), + pytest.param(lambda: RichGroup(name="grp", callback=lambda: None).command("cli"), id="7"), + pytest.param(lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).command, id="8"), + pytest.param(lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).command("cli"), id="9"), + ], +) +def test_rich_config_decorator_order( + invoke: InvokeCli, assert_str: AssertStr, command_callable: Callable[..., Any] +) -> None: + @command_callable() + @rich_config(Console(), RichHelpConfiguration(max_width=60, use_markdown=True)) + def cli() -> None: """Some help # Header @@ -237,24 +256,26 @@ def cli(): assert_str( result.stdout, """ -Usage: cli [OPTIONS] - - Some help - # Header - -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│ --help Show this message and exit. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Usage: cli [OPTIONS] + + Some help + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ Header ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +╭─ Options ────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────╯ """, ) -def test_rich_config_max_width(invoke: InvokeCli, assert_str: AssertStr): +def test_rich_config_max_width(invoke: InvokeCli, assert_str: AssertStr) -> None: rc.WIDTH = 100 rc.MAX_WIDTH = 64 @command() - def cli(): + def cli() -> None: """Some help # Header @@ -279,12 +300,12 @@ def cli(): @pytest.mark.skipif(CLICK_IS_BEFORE_VERSION_8X, reason="rich_config not supported prior to click v8") -def test_rich_config_context_settings(invoke: InvokeCli): +def test_rich_config_context_settings(invoke: InvokeCli) -> None: @click.command( cls=RichCommand, context_settings={"rich_console": Console(), "rich_help_config": RichHelpConfiguration()} ) @pass_context - def cli(ctx: RichContext): + def cli(ctx: RichContext) -> None: assert isinstance(ctx, RichContext) assert ctx.console is not None assert ctx.help_config is not None @@ -295,12 +316,12 @@ def cli(ctx: RichContext): @pytest.mark.skipif(not CLICK_IS_BEFORE_VERSION_8X, reason="This is to test a warning when using for click v7.") -def test_rich_config_warns_before_click_v8(invoke: InvokeCli): +def test_rich_config_warns_before_click_v8(invoke: InvokeCli) -> None: with pytest.warns(RuntimeWarning, match="does not work with versions of click prior to version 8[.]0[.]0"): @rich_config(help_config=RichHelpConfiguration()) @click.command("test-cmd") - def cli(): + def cli() -> None: # Command should still work, regardless. click.echo("hello, world!") From b89191ad4a8c866c9a4438fee8010f10d88cab1e Mon Sep 17 00:00:00 2001 From: dwreeves Date: Fri, 6 Oct 2023 17:00:52 -0400 Subject: [PATCH 05/11] flesh out test --- tests/test_help.py | 111 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 25 deletions(-) diff --git a/tests/test_help.py b/tests/test_help.py index 44cf9c37..25ca7fd5 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -8,7 +8,7 @@ from rich.console import Console import rich_click.rich_click as rc -from rich_click import command, pass_context, rich_config, RichContext, RichHelpConfiguration +from rich_click import command, group, pass_context, rich_config, RichContext, RichHelpConfiguration from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X, CLICK_IS_VERSION_80 from rich_click.rich_command import RichCommand, RichGroup @@ -209,25 +209,97 @@ def test_rich_click( class ClickGroupWithRichCommandClass(click.Group): command_class = RichCommand + group_class = RichGroup + + +command_help_output = """ + Usage: cli [OPTIONS] + + Some help + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ Header ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +╭─ Options ────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────╯ +""" + + +group_help_output = """ + Usage: cli [OPTIONS] COMMAND [ARGS]... + + Some help + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ Header ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +╭─ Options ────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────╯ +""" @pytest.mark.skipif(CLICK_IS_BEFORE_VERSION_8X, reason="rich_config not supported prior to click v8") @pytest.mark.parametrize( - "command_callable", + ("command_callable", "expected_command_type", "expected_help_output"), [ - pytest.param(lambda: command, id="1"), - pytest.param(lambda: command(), id="2"), - pytest.param(lambda: command("cli"), id="3"), - pytest.param(lambda: click.command(cls=RichCommand), id="4"), - pytest.param(lambda: click.command("cli", cls=RichCommand), id="5"), - pytest.param(lambda: RichGroup(name="grp", callback=lambda: None).command, id="6"), - pytest.param(lambda: RichGroup(name="grp", callback=lambda: None).command("cli"), id="7"), - pytest.param(lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).command, id="8"), - pytest.param(lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).command("cli"), id="9"), + pytest.param(lambda: command, RichCommand, command_help_output, id="command1"), + pytest.param(lambda: command(), RichCommand, command_help_output, id="command2"), + pytest.param(lambda: command("cli"), RichCommand, command_help_output, id="command3"), + pytest.param(lambda: group, RichGroup, group_help_output, id="group1"), + pytest.param(lambda: group(), RichGroup, group_help_output, id="group2"), + pytest.param(lambda: group("cli"), RichGroup, group_help_output, id="group3"), + pytest.param(lambda: click.command(cls=RichCommand), RichCommand, command_help_output, id="click_command1"), + pytest.param( + lambda: click.command("cli", cls=RichCommand), RichCommand, command_help_output, id="click_command2" + ), + pytest.param(lambda: click.group(cls=RichGroup), RichGroup, group_help_output, id="click_group1"), + pytest.param(lambda: click.group("cli", cls=RichGroup), RichGroup, group_help_output, id="click_group2"), + pytest.param( + lambda: RichGroup(name="grp", callback=lambda: None).command, + RichCommand, + command_help_output, + id="RichGroup1", + ), + pytest.param( + lambda: RichGroup(name="grp", callback=lambda: None).command("cli"), + RichCommand, + command_help_output, + id="RichGroup2", + ), + pytest.param( + lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).command, + RichCommand, + command_help_output, + id="ClickGroup1", + ), + pytest.param( + lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).command("cli"), + RichCommand, + command_help_output, + id="ClickGroup2", + ), + pytest.param( + lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).group, + RichGroup, + group_help_output, + id="ClickGroup3", + ), + pytest.param( + lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).group("cli"), + RichGroup, + group_help_output, + id="ClickGroup4", + ), ], ) def test_rich_config_decorator_order( - invoke: InvokeCli, assert_str: AssertStr, command_callable: Callable[..., Any] + invoke: InvokeCli, + assert_str: AssertStr, + command_callable: Callable[..., Any], + expected_command_type: Type[RichCommand], + expected_help_output: str, ) -> None: @command_callable() @rich_config(Console(), RichHelpConfiguration(max_width=60, use_markdown=True)) @@ -239,7 +311,7 @@ def cli() -> None: pass assert hasattr(cli, "__rich_context_settings__") is False - assert isinstance(cli, RichCommand) + assert type(cli) is expected_command_type assert cli.console is not None assert cli.__doc__ is not None assert_str( @@ -255,18 +327,7 @@ def cli() -> None: assert_str( result.stdout, - """ - Usage: cli [OPTIONS] - - Some help - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ Header ┃ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -╭─ Options ────────────────────────────────────────────────╮ -│ --help Show this message and exit. │ -╰──────────────────────────────────────────────────────────╯ - """, + expected_help_output, ) From fbc617a4a56b991f8041283129844fd0c9dfe8db Mon Sep 17 00:00:00 2001 From: dwreeves Date: Fri, 6 Oct 2023 17:51:30 -0400 Subject: [PATCH 06/11] fix click 7 tests --- tests/fixtures/simple.py | 4 ++-- tests/fixtures/table_alignment.py | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/simple.py b/tests/fixtures/simple.py index cca1d7b4..c465c62a 100644 --- a/tests/fixtures/simple.py +++ b/tests/fixtures/simple.py @@ -1,7 +1,7 @@ import rich_click as click -@click.group +@click.group() @click.option( "--debug/--no-debug", "-d/-n", @@ -24,7 +24,7 @@ def cli(debug) -> None: print(f"Debug mode is {'on' if debug else 'off'}") -@cli.command +@cli.command() @click.option( "--type", required=True, diff --git a/tests/fixtures/table_alignment.py b/tests/fixtures/table_alignment.py index 252ae883..992c4464 100644 --- a/tests/fixtures/table_alignment.py +++ b/tests/fixtures/table_alignment.py @@ -1,4 +1,5 @@ import rich_click as click +from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X click.rich_click.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (1, 2) @@ -97,7 +98,13 @@ def auth() -> None: print("Downloading") -@cli.command +if CLICK_IS_BEFORE_VERSION_8X: + cmd_dec = cli.command() +else: + cmd_dec = cli.command + + +@cmd_dec def config() -> None: """Set up the configuration.""" print("Downloading") From 833e39de26e9f2e165471b603017b64aa01b3980 Mon Sep 17 00:00:00 2001 From: dwreeves Date: Fri, 6 Oct 2023 18:18:16 -0400 Subject: [PATCH 07/11] fix test suite --- setup.py | 11 ++++- tests/fixtures/table_alignment.py | 4 +- tests/test_help.py | 75 ++++++++++++++++++++++++------- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/setup.py b/setup.py index 677bf15d..bf056de4 100644 --- a/setup.py +++ b/setup.py @@ -3,6 +3,15 @@ setup( install_requires=["click>=7", "rich>=10.7.0", "importlib-metadata; python_version < '3.8'", "typing_extensions"], extras_require={ - "dev": ["pre-commit", "pytest", "flake8", "flake8-docstrings", "pytest-cov", "packaging", "types-setuptools"], + "dev": [ + "mypy", + "pre-commit", + "pytest", + "flake8", + "flake8-docstrings", + "pytest-cov", + "packaging", + "types-setuptools", + ], }, ) diff --git a/tests/fixtures/table_alignment.py b/tests/fixtures/table_alignment.py index 992c4464..24b758d0 100644 --- a/tests/fixtures/table_alignment.py +++ b/tests/fixtures/table_alignment.py @@ -1,5 +1,5 @@ import rich_click as click -from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X +from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X, CLICK_IS_VERSION_80 click.rich_click.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (1, 2) @@ -98,7 +98,7 @@ def auth() -> None: print("Downloading") -if CLICK_IS_BEFORE_VERSION_8X: +if CLICK_IS_BEFORE_VERSION_8X or CLICK_IS_VERSION_80: cmd_dec = cli.command() else: cmd_dec = cli.command diff --git a/tests/test_help.py b/tests/test_help.py index 25ca7fd5..0d84ad46 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -212,21 +212,45 @@ class ClickGroupWithRichCommandClass(click.Group): group_class = RichGroup -command_help_output = """ +if rich_version.major == 12: + command_help_output = """ Usage: cli [OPTIONS] + + Some help + ╔════════════════════════════════════════════════════════╗ + ║ Header ║ + ╚════════════════════════════════════════════════════════╝ + +╭─ Options ────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────╯ +""" + group_help_output = """ + Usage: cli [OPTIONS] COMMAND [ARGS]... Some help - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ Header ┃ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + ╔════════════════════════════════════════════════════════╗ + ║ Header ║ + ╚════════════════════════════════════════════════════════╝ ╭─ Options ────────────────────────────────────────────────╮ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────╯ """ +else: + command_help_output = """ + Usage: cli [OPTIONS] + Some help + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + ┃ Header ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -group_help_output = """ +╭─ Options ────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────╯ +""" + group_help_output = """ Usage: cli [OPTIONS] COMMAND [ARGS]... Some help @@ -244,10 +268,26 @@ class ClickGroupWithRichCommandClass(click.Group): @pytest.mark.parametrize( ("command_callable", "expected_command_type", "expected_help_output"), [ - pytest.param(lambda: command, RichCommand, command_help_output, id="command1"), + pytest.param( + lambda: command, + RichCommand, + command_help_output, + marks=pytest.mark.skipif( + click_version < version.parse("8.1.0"), reason="decorator must be called prior to click 8.1.0" + ), + id="command1", + ), pytest.param(lambda: command(), RichCommand, command_help_output, id="command2"), pytest.param(lambda: command("cli"), RichCommand, command_help_output, id="command3"), - pytest.param(lambda: group, RichGroup, group_help_output, id="group1"), + pytest.param( + lambda: group, + RichGroup, + group_help_output, + id="group1", + marks=pytest.mark.skipif( + click_version < version.parse("8.1.0"), reason="decorator must be called prior to click 8.1.0" + ), + ), pytest.param(lambda: group(), RichGroup, group_help_output, id="group2"), pytest.param(lambda: group("cli"), RichGroup, group_help_output, id="group3"), pytest.param(lambda: click.command(cls=RichCommand), RichCommand, command_help_output, id="click_command1"), @@ -261,6 +301,9 @@ class ClickGroupWithRichCommandClass(click.Group): RichCommand, command_help_output, id="RichGroup1", + marks=pytest.mark.skipif( + click_version < version.parse("8.1.0"), reason="decorator must be called prior to click 8.1.0" + ), ), pytest.param( lambda: RichGroup(name="grp", callback=lambda: None).command("cli"), @@ -273,6 +316,9 @@ class ClickGroupWithRichCommandClass(click.Group): RichCommand, command_help_output, id="ClickGroup1", + marks=pytest.mark.skipif( + click_version < version.parse("8.1.0"), reason="decorator must be called prior to click 8.1.0" + ), ), pytest.param( lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).command("cli"), @@ -285,6 +331,9 @@ class ClickGroupWithRichCommandClass(click.Group): RichGroup, group_help_output, id="ClickGroup3", + marks=pytest.mark.skipif( + click_version < version.parse("8.1.0"), reason="decorator must be called prior to click 8.1.0" + ), ), pytest.param( lambda: ClickGroupWithRichCommandClass(name="grp", callback=lambda: None).group("cli"), @@ -326,8 +375,8 @@ def cli() -> None: result = invoke(cli, "--help") assert_str( - result.stdout, - expected_help_output, + actual=result.stdout, + expectation=expected_help_output, ) @@ -337,10 +386,7 @@ def test_rich_config_max_width(invoke: InvokeCli, assert_str: AssertStr) -> None @command() def cli() -> None: - """Some help - - # Header - """ + """Some help text""" pass result = invoke(cli, "--help") @@ -350,8 +396,7 @@ def cli() -> None: """ Usage: cli [OPTIONS] - Some help - # Header + Some help text ╭─ Options ────────────────────────────────────────────────────╮ │ --help Show this message and exit. │ From 74c4b4faba6d19ac1b1c664e7c68bc2f44adceb3 Mon Sep 17 00:00:00 2001 From: dwreeves Date: Fri, 6 Oct 2023 19:21:39 -0400 Subject: [PATCH 08/11] rich context settings inherit from parent --- src/rich_click/__init__.py | 4 +--- src/rich_click/cli.py | 3 ++- src/rich_click/decorators.py | 12 +++++++++--- src/rich_click/rich_command.py | 18 +++++++++++++++++- src/rich_click/rich_context.py | 21 +++++++-------------- 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/rich_click/__init__.py b/src/rich_click/__init__.py index 217c49c7..fc063874 100644 --- a/src/rich_click/__init__.py +++ b/src/rich_click/__init__.py @@ -78,7 +78,6 @@ from rich_click.decorators import rich_config as rich_config from rich_click.rich_command import RichCommand as RichCommand from rich_click.rich_command import RichGroup as RichGroup -from rich_click.rich_command import RichMultiCommand as RichMultiCommand from rich_click.rich_context import RichContext as RichContext from rich_click.rich_help_configuration import RichHelpConfiguration as RichHelpConfiguration @@ -92,7 +91,7 @@ def __getattr__(name: str) -> object: import warnings warnings.warn( - "'RichMultiCommand' is deprecated and will be removed in Click 9.0. Use" " 'RichGroup' instead.", + "'RichMultiCommand' is deprecated and will be removed in Click 9.0. Use 'RichGroup' instead.", DeprecationWarning, stacklevel=2, ) @@ -100,6 +99,5 @@ def __getattr__(name: str) -> object: return RichMultiCommand - # Support for potentially deprecated objects in newer versions of click: else: return getattr(click, name) diff --git a/src/rich_click/cli.py b/src/rich_click/cli.py index b1a96397..f9483c9c 100644 --- a/src/rich_click/cli.py +++ b/src/rich_click/cli.py @@ -68,7 +68,8 @@ def patch() -> None: click.command = rich_command click.Group = RichGroup # type: ignore[misc] click.Command = RichCommand # type: ignore[misc] - click.MultiCommand = RichMultiCommand # type: ignore[misc] + if "MultiCommand" in dir(click): + click.MultiCommand = RichMultiCommand # type: ignore[assignment,misc] def main(args: Optional[List[str]] = None) -> Any: diff --git a/src/rich_click/decorators.py b/src/rich_click/decorators.py index a0a6b7c8..f806c9da 100644 --- a/src/rich_click/decorators.py +++ b/src/rich_click/decorators.py @@ -7,7 +7,7 @@ __version__ = "1.7.0dev" -from typing import Any, Callable, cast, Optional, overload, Type, TYPE_CHECKING, TypeVar, Union +from typing import Any, Callable, cast, Dict, Optional, overload, Type, TYPE_CHECKING, TypeVar, Union from click import Command from click import command as click_command @@ -177,10 +177,16 @@ def decorator_with_warning(obj: FC) -> FC: return decorator_with_warning def decorator(obj: FC) -> FC: + extra: Dict[str, Any] = {} + if console is not None: + extra["rich_console"] = console + if help_config is not None: + extra["rich_help_config"] = help_config + if isinstance(obj, (RichCommand, RichGroup)): - obj.context_settings.update({"rich_console": console, "rich_help_config": help_config}) + obj.context_settings.update(extra) elif callable(obj) and not isinstance(obj, (Command, Group)): - setattr(obj, "__rich_context_settings__", {"rich_console": console, "rich_help_config": help_config}) + setattr(obj, "__rich_context_settings__", extra) else: raise NotSupportedError("`rich_config` requires a `RichCommand` or `RichGroup`. Try using the cls keyword") return obj diff --git a/src/rich_click/rich_command.py b/src/rich_click/rich_command.py index 5caf6bd0..2428e055 100644 --- a/src/rich_click/rich_command.py +++ b/src/rich_click/rich_command.py @@ -30,10 +30,14 @@ class RichCommand(click.Command): def __init__(self, *args: Any, **kwargs: Any): """Create Rich Command instance.""" super().__init__(*args, **kwargs) + self._register_rich_context_settings_from_callback() + + def _register_rich_context_settings_from_callback(self) -> None: if self.callback is not None: if hasattr(self.callback, "__rich_context_settings__"): rich_context_settings = getattr(self.callback, "__rich_context_settings__", {}) - self.context_settings.update(rich_context_settings) + for k, v in rich_context_settings.items(): + self.context_settings.setdefault(k, v) del self.callback.__rich_context_settings__ @property @@ -166,6 +170,12 @@ class RichMultiCommand(RichCommand, click.MultiCommand): to print richly formatted output. """ + @wraps(click.MultiCommand.__init__) + def __init__(self, *args, **kwargs): + """Initialize RichGroup class.""" + click.MultiCommand.__init__(self, *args, **kwargs) + self._register_rich_context_settings_from_callback() + class RichGroup(RichCommand, click.Group): """Richly formatted click Group. @@ -177,6 +187,12 @@ class RichGroup(RichCommand, click.Group): command_class: Type[RichCommand] = RichCommand group_class = type + @wraps(click.Group.__init__) + def __init__(self, *args, **kwargs): + """Initialize RichGroup class.""" + click.Group.__init__(self, *args, **kwargs) + self._register_rich_context_settings_from_callback() + if CLICK_IS_BEFORE_VERSION_8X: @overload diff --git a/src/rich_click/rich_context.py b/src/rich_click/rich_context.py index f54b5fdb..ea544e09 100644 --- a/src/rich_click/rich_context.py +++ b/src/rich_click/rich_context.py @@ -28,24 +28,17 @@ def __init__( Defaults to None. """ super().__init__(*args, **kwargs) - self._console = rich_console - self._help_config = rich_help_config + parent: Optional[RichContext] = kwargs.pop("parent", None) - @property - def console(self) -> Optional[Console]: - """Rich Console instance for displaying beautfil application output in the terminal. + if rich_console is None and hasattr(parent, "console"): + rich_console = parent.console # type: ignore[has-type,union-attr] - NOTE: This is a separate instance from the one used by the help formatter, and allows full control of the - console configuration. + self.console = rich_console - See `rich_config` decorator for how to apply the settings. - """ - return self._console + if rich_help_config is None and hasattr(parent, "help_config"): + rich_help_config = parent.help_config # type: ignore[has-type,union-attr] - @property - def help_config(self) -> Optional[RichHelpConfiguration]: - """Rich help configuration.""" - return self._help_config + self.help_config = rich_help_config def make_formatter(self): """Create the Rich Help Formatter.""" From 57f7e123156875fb95f04a25bc37575a992c18de Mon Sep 17 00:00:00 2001 From: dwreeves Date: Fri, 6 Oct 2023 19:26:28 -0400 Subject: [PATCH 09/11] misc change --- src/rich_click/rich_context.py | 4 +++- src/rich_click/rich_help_formatter.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/rich_click/rich_context.py b/src/rich_click/rich_context.py index ea544e09..06dc1d51 100644 --- a/src/rich_click/rich_context.py +++ b/src/rich_click/rich_context.py @@ -42,4 +42,6 @@ def __init__( def make_formatter(self): """Create the Rich Help Formatter.""" - return self.formatter_class(config=self.help_config) + return self.formatter_class( + width=self.terminal_width, max_width=self.max_content_width, config=self.help_config + ) diff --git a/src/rich_click/rich_help_formatter.py b/src/rich_click/rich_help_formatter.py index 3e5cca88..4e42dd90 100644 --- a/src/rich_click/rich_help_formatter.py +++ b/src/rich_click/rich_help_formatter.py @@ -93,6 +93,10 @@ def __init__( config: Configuration. Defaults to None. """ + if config is not None: + # Rich config overrides width and max width if set. + width = config.width or width + max_width = config.max_width or max_width super().__init__(indent_increment, width, max_width, *args, **kwargs) self._rich_buffer = TerminalBuffer() self._config = config or get_module_config() From 5b09dc26bcff26064a699691c61f003bbc9998ac Mon Sep 17 00:00:00 2001 From: dwreeves Date: Fri, 6 Oct 2023 20:04:17 -0400 Subject: [PATCH 10/11] make mypy work in strict mode --- CHANGELOG.md | 6 ++++-- pyproject.toml | 2 +- src/rich_click/__init__.py | 5 ++--- src/rich_click/rich_click.py | 6 +++--- src/rich_click/rich_command.py | 14 ++++++++------ src/rich_click/rich_context.py | 2 +- tests/__init__.py | 0 tests/fixtures/context_settings.py | 2 +- tests/fixtures/custom_errors.py | 2 +- tests/fixtures/declarative.py | 2 +- tests/fixtures/envvar.py | 4 ++-- tests/fixtures/groups_sorting.py | 14 +++++++------- tests/fixtures/markdown.py | 2 +- tests/fixtures/metavars.py | 2 +- tests/fixtures/metavars_default.py | 2 +- tests/fixtures/rich_markup.py | 2 +- tests/fixtures/simple.py | 6 +++--- tests/fixtures/table_alignment.py | 6 +++--- tests/fixtures/table_styles.py | 10 +++++----- tests/test_exit_code.py | 6 +++--- tests/test_help.py | 13 +++++++++---- 21 files changed, 58 insertions(+), 50 deletions(-) create mode 100644 tests/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc956dc..ea4056f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,11 @@ This release comes after merging a huge pull-request from [@BrutalSimplicity](ht This PR closes a number of issues: - [#25](https://github.com/ewels/rich-click/issues/25): Add tests! -- [#38](https://github.com/ewels/rich-click/issues/38): Support `click.MultiCommand` - [#90](https://github.com/ewels/rich-click/issues/90): `click.ClickException` should output to `stderr` - [#88](https://github.com/ewels/rich-click/issues/88): Rich Click breaks contract of Click's `format_help` and its callers - [#18](https://github.com/ewels/rich-click/issues/18): Options inherited from context settings aren't applied -- [#114](https://github.com/ewels/rich-click/issues/114): `ctx.exit(exit_code)` not showing nonzero exit codes. + +In addition, we merged another large pull-request that adds **full static type-checking support** (see issue [#85](https://github.com/ewels/rich-click/issues/85)), and fixes many bugs - see PR [#126](https://github.com/ewels/rich-click/pull/126). In addition: @@ -32,6 +32,8 @@ In addition: - Add new style option `WIDTH` (in addition to `MAX_WIDTH`), thanks to [@ealap](httpsd://github.com/ealap) [[#110](https://github.com/ewels/rich-click/pull/110)] - Updated styling for `Usage:` line to avoid off-target effects [[#108](https://github.com/ewels/rich-click/issues/108)] - Click 7.x support has been deprecated. [[#117](https://github.com/ewels/rich-click/pull/117)] +- Fixed error where `ctx.exit(exit_code)` would not show nonzero exit codes.[[#114](https://github.com/ewels/rich-click/issues/114)] +- Support `click.MultiCommand`. [[#38](https://github.com/ewels/rich-click/issues/38)]: ## Version 1.6.1 (2023-01-19) diff --git a/pyproject.toml b/pyproject.toml index 1c915cc3..bb4a7ff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ profile = "black" [tool.mypy] python_version = "3.8" scripts_are_modules = "True" -# strict = "True" +strict = "True" [tool.pyright] include = ["src"] diff --git a/src/rich_click/__init__.py b/src/rich_click/__init__.py index fc063874..f6b40d90 100644 --- a/src/rich_click/__init__.py +++ b/src/rich_click/__init__.py @@ -12,7 +12,6 @@ # We need to manually import these instead of `from click import *` to force mypy to recognize a few type annotation overrides for the rich_click decorators. from click.core import Argument as Argument from click.core import Command as Command -from click.core import CommandCollection as CommandCollection from click.core import Context as Context from click.core import Group as Group from click.core import Option as Option @@ -83,8 +82,6 @@ def __getattr__(name: str) -> object: - import click - from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_9X if name == "RichMultiCommand" and CLICK_IS_BEFORE_VERSION_9X: @@ -100,4 +97,6 @@ def __getattr__(name: str) -> object: return RichMultiCommand else: + import click + return getattr(click, name) diff --git a/src/rich_click/rich_click.py b/src/rich_click/rich_click.py index 99d0fbb7..63f21d90 100644 --- a/src/rich_click/rich_click.py +++ b/src/rich_click/rich_click.py @@ -495,7 +495,7 @@ def rich_format_help( if isinstance(param, click.core.Argument) and not config.group_arguments_options: argument_group_options.append(param.opts[0]) else: - list_of_option_groups: List = option_groups[-1]["options"] # type: ignore[assignment] + list_of_option_groups: List[str] = option_groups[-1]["options"] # type: ignore[assignment] list_of_option_groups.append(param.opts[0]) # If we're not grouping arguments and we got some, prepend before default options @@ -643,7 +643,7 @@ class MetavarHighlighter(RegexHighlighter): if command in cmd_group.get("commands", []): break else: - commands: List = cmd_groups[-1]["commands"] # type: ignore[assignment] + commands: List[str] = cmd_groups[-1]["commands"] # type: ignore[assignment] commands.append(command) # Print each command group panel @@ -717,7 +717,7 @@ class MetavarHighlighter(RegexHighlighter): console.print(Padding(_make_rich_rext(config.footer_text, config.style_footer_text, formatter), (1, 1, 0, 1))) -def rich_format_error(self: click.ClickException, formatter: Optional[RichHelpFormatter] = None): +def rich_format_error(self: click.ClickException, formatter: Optional[RichHelpFormatter] = None) -> None: """Print richly formatted click errors. Called by custom exception handler to print richly formatted click errors. diff --git a/src/rich_click/rich_command.py b/src/rich_click/rich_command.py index 2428e055..481d7839 100644 --- a/src/rich_click/rich_command.py +++ b/src/rich_click/rich_command.py @@ -7,10 +7,12 @@ import click from click.utils import make_str, PacifyFlushWrapper +from rich.console import Console from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X from rich_click.rich_click import rich_abort_error, rich_format_error, rich_format_help from rich_click.rich_context import RichContext +from rich_click.rich_help_configuration import RichHelpConfiguration from rich_click.rich_help_formatter import RichHelpFormatter @@ -41,7 +43,7 @@ def _register_rich_context_settings_from_callback(self) -> None: del self.callback.__rich_context_settings__ @property - def console(self): + def console(self) -> Optional[Console]: """Rich Console. This is a separate instance from the help formatter that allows full control of the @@ -52,7 +54,7 @@ def console(self): return self.context_settings.get("rich_console") @property - def help_config(self): + def help_config(self) -> Optional[RichHelpConfiguration]: """Rich Help Configuration.""" return self.context_settings.get("rich_help_config") @@ -156,7 +158,7 @@ def main( sys.stderr.write(self.formatter.getvalue()) sys.exit(1) - def format_help(self, ctx: click.Context, formatter: click.HelpFormatter): + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: rich_format_help(self, ctx, formatter) @@ -171,7 +173,7 @@ class RichMultiCommand(RichCommand, click.MultiCommand): """ @wraps(click.MultiCommand.__init__) - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def] """Initialize RichGroup class.""" click.MultiCommand.__init__(self, *args, **kwargs) self._register_rich_context_settings_from_callback() @@ -188,7 +190,7 @@ class RichGroup(RichCommand, click.Group): group_class = type @wraps(click.Group.__init__) - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize RichGroup class.""" click.Group.__init__(self, *args, **kwargs) self._register_rich_context_settings_from_callback() @@ -209,4 +211,4 @@ def command( # This method override is required for Click 7.x compatibility. # (The command_class ClassVar was not added until 8.0.) kwargs.setdefault("cls", self.command_class) - return super().command(*args, **kwargs) + return super().command(*args, **kwargs) # type: ignore[no-any-return] diff --git a/src/rich_click/rich_context.py b/src/rich_click/rich_context.py index 06dc1d51..8e5e0a2b 100644 --- a/src/rich_click/rich_context.py +++ b/src/rich_click/rich_context.py @@ -40,7 +40,7 @@ def __init__( self.help_config = rich_help_config - def make_formatter(self): + def make_formatter(self) -> RichHelpFormatter: """Create the Rich Help Formatter.""" return self.formatter_class( width=self.terminal_width, max_width=self.max_content_width, config=self.help_config diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/context_settings.py b/tests/fixtures/context_settings.py index 0b3d6fcd..79f1d247 100644 --- a/tests/fixtures/context_settings.py +++ b/tests/fixtures/context_settings.py @@ -10,7 +10,7 @@ show_default="show me in c8+", help="Show 'default: (show me in c8+)' in click>=8.0. In click 7, no default is shown.", ) -def cli(a: str, b: str, c: str, d: str): +def cli(a: str, b: str, c: str, d: str) -> None: """ Test cases for context_settings. diff --git a/tests/fixtures/custom_errors.py b/tests/fixtures/custom_errors.py index f15a6be1..eb803989 100644 --- a/tests/fixtures/custom_errors.py +++ b/tests/fixtures/custom_errors.py @@ -18,7 +18,7 @@ ) @click.option("--all", is_flag=True, help="Sync all the things?") @click.option("--debug", is_flag=True, default=False, help="Enable debug mode") -def cli(input, type, all, debug) -> None: +def cli(input: str, type: str, all: bool, debug: bool) -> None: """ My amazing tool does all the things. diff --git a/tests/fixtures/declarative.py b/tests/fixtures/declarative.py index 5b09f7a0..544de4b3 100644 --- a/tests/fixtures/declarative.py +++ b/tests/fixtures/declarative.py @@ -5,7 +5,7 @@ @click.group(cls=RichGroup) @click.option("--debug/--no-debug", default=False) -def cli(debug) -> None: +def cli(debug: bool) -> None: """ My amazing tool does all the things. diff --git a/tests/fixtures/envvar.py b/tests/fixtures/envvar.py index 359ac892..e9b7dbe1 100644 --- a/tests/fixtures/envvar.py +++ b/tests/fixtures/envvar.py @@ -8,7 +8,7 @@ @click.group(context_settings=dict(auto_envvar_prefix="GREETER")) @click.option("--debug/--no-debug") -def cli(debug) -> None: +def cli(debug: bool) -> None: click.echo(f"Debug mode is {'on' if debug else 'off'}") @@ -29,7 +29,7 @@ def cli(debug) -> None: show_default=True, help="This can be set via env var EMAIL or EMAIL_ADDRESS", ) -def greet(username, nickname, email) -> None: +def greet(username: str, nickname: str, email: str) -> None: click.echo(f"Hello {username} ({nickname}) with email {email}!") diff --git a/tests/fixtures/groups_sorting.py b/tests/fixtures/groups_sorting.py index 21f9514a..820642cc 100644 --- a/tests/fixtures/groups_sorting.py +++ b/tests/fixtures/groups_sorting.py @@ -56,7 +56,7 @@ help="Show the debug log messages", ) @click.version_option("1.23", prog_name="mytool") -def cli(type, debug): +def cli(type: str, debug: bool) -> None: """ My amazing tool does all the things. @@ -70,30 +70,30 @@ def cli(type, debug): @cli.command() -@click.option("--input", "-i", required=True, help="Input path") +@click.option("--input", "-i", "input_", required=True, help="Input path") @click.option("--output", "-o", help="Output path") -@click.option("--all", is_flag=True, help="Sync all the things?") +@click.option("--all", "all_", is_flag=True, help="Sync all the things?") @click.option("--overwrite", is_flag=True, help="Overwrite local files") -def sync(input, output, all, overwrite): +def sync(input_: str, output: str, all_: bool, overwrite: bool) -> None: """Synchronise all your files between two places.""" print("Syncing") @cli.command() @click.option("--all", is_flag=True, help="Get everything") -def download(all): +def download(all: bool) -> None: """Pretend to download some files from somewhere.""" print("Downloading") @cli.command() -def auth(): +def auth() -> None: """Authenticate the app.""" print("Downloading") @cli.command() -def config(): +def config() -> None: """Set up the configuration.""" print("Downloading") diff --git a/tests/fixtures/markdown.py b/tests/fixtures/markdown.py index 434550ce..eb1c1243 100644 --- a/tests/fixtures/markdown.py +++ b/tests/fixtures/markdown.py @@ -18,7 +18,7 @@ ) @click.option("--all", is_flag=True, help="Sync\n 1. all\n 2. the\n 3. things?") @click.option("--debug", is_flag=True, help="# Enable `debug mode`") -def cli(input, type, all, debug) -> None: +def cli(input: str, type: str, all: bool, debug: bool) -> None: """ My amazing tool does _**all the things**_. diff --git a/tests/fixtures/metavars.py b/tests/fixtures/metavars.py index acead524..c2d02026 100644 --- a/tests/fixtures/metavars.py +++ b/tests/fixtures/metavars.py @@ -45,7 +45,7 @@ show_default=True, help="This click choice has loads of options.", ) -def cli(debug, number) -> None: +def cli(debug: bool, number: str) -> None: """ My amazing tool does all the things. diff --git a/tests/fixtures/metavars_default.py b/tests/fixtures/metavars_default.py index 267cda36..ffa54b9f 100644 --- a/tests/fixtures/metavars_default.py +++ b/tests/fixtures/metavars_default.py @@ -42,7 +42,7 @@ show_default=True, help="This click choice has loads of options.", ) -def cli(debug, number) -> None: +def cli(debug: bool, number: str) -> None: """ My amazing tool does all the things. diff --git a/tests/fixtures/rich_markup.py b/tests/fixtures/rich_markup.py index ac03a22b..c9760753 100644 --- a/tests/fixtures/rich_markup.py +++ b/tests/fixtures/rich_markup.py @@ -18,7 +18,7 @@ ) @click.option("--all", is_flag=True, help="Sync all the things?") @click.option("--debug", is_flag=True, help="Enable :point_right: [yellow]debug mode[/] :point_left:") -def cli(input, type, all, debug): +def cli(input: str, type: str, all: bool, debug: bool) -> None: """ My amazing tool does [black on blue] all the things [/]. diff --git a/tests/fixtures/simple.py b/tests/fixtures/simple.py index c465c62a..5251a40e 100644 --- a/tests/fixtures/simple.py +++ b/tests/fixtures/simple.py @@ -11,7 +11,7 @@ Double newlines are preserved.""", ) -def cli(debug) -> None: +def cli(debug: bool) -> None: """ My amazing tool does all the things. @@ -33,7 +33,7 @@ def cli(debug) -> None: help="Type of file to sync", ) @click.option("--all", is_flag=True) -def sync(type, all) -> None: +def sync(type: str, all: bool) -> None: """Synchronise all your files between two places. Example command that doesn't do much except print to the terminal.""" print("Syncing") @@ -41,7 +41,7 @@ def sync(type, all) -> None: @cli.command(short_help="Optionally use short-help for the group help text") @click.option("--all", is_flag=True, help="Get everything") -def download(all) -> None: +def download(all: bool) -> None: """ Pretend to download some files from somewhere. Multi-line help strings are unwrapped diff --git a/tests/fixtures/table_alignment.py b/tests/fixtures/table_alignment.py index 24b758d0..08197305 100644 --- a/tests/fixtures/table_alignment.py +++ b/tests/fixtures/table_alignment.py @@ -59,7 +59,7 @@ help="Show the debug log messages", ) @click.version_option("1.23", prog_name="mytool") -def cli(type, debug) -> None: +def cli(type: str, debug: bool) -> None: """ My amazing tool does all the things. @@ -77,7 +77,7 @@ def cli(type, debug) -> None: @click.option("--output", "-o", help="Output path") @click.option("--all", is_flag=True, help="Sync all the things?") @click.option("--overwrite", is_flag=True, help="Overwrite local files") -def sync(input, output, all, overwrite) -> None: +def sync(input: str, output: str, all: bool, overwrite: bool) -> None: """Synchronise all your files between two places.""" print("Syncing") @@ -87,7 +87,7 @@ def sync(input, output, all, overwrite) -> None: @cli.command() @click.option("--all", is_flag=True, help="Get everything") -def download(all) -> None: +def download(all: bool) -> None: """Pretend to download some files from somewhere.""" print("Downloading") diff --git a/tests/fixtures/table_styles.py b/tests/fixtures/table_styles.py index 99ead0e1..7c9191a9 100644 --- a/tests/fixtures/table_styles.py +++ b/tests/fixtures/table_styles.py @@ -55,7 +55,7 @@ """, ) @click.version_option("1.23", prog_name="mytool") -def cli(type, debug): +def cli(type: str, debug: bool) -> None: """ My amazing tool does all the things. @@ -73,7 +73,7 @@ def cli(type, debug): @click.option("--output", "-o", help="Output path") @click.option("--all", is_flag=True, help="Sync all the things?") @click.option("--overwrite", is_flag=True, help="Overwrite local files") -def sync(input, output, all, overwrite): +def sync(input: str, output: str, all: bool, overwrite: bool) -> None: """ Synchronise all your files between two places. Curabitur congue eget lorem in lacinia. @@ -96,7 +96,7 @@ def sync(input, output, all, overwrite): @cli.command() @click.option("--all", is_flag=True, help="Get everything") -def download(all): +def download(all: bool) -> None: """ Pretend to download some files from somewhere. Integer bibendum libero nunc, sed aliquet ex tincidunt vel. @@ -112,7 +112,7 @@ def download(all): @cli.command() -def auth(): +def auth() -> None: """ Authenticate the app. Duis lacus nibh, feugiat a nibh a, commodo dictum libero. @@ -128,7 +128,7 @@ def auth(): @cli.command() -def config(): +def config() -> None: """ Set up the configuration. Sed accumsan ornare odio dictum aliquam. diff --git a/tests/test_exit_code.py b/tests/test_exit_code.py index 7ba57fe2..f7a364b1 100644 --- a/tests/test_exit_code.py +++ b/tests/test_exit_code.py @@ -23,17 +23,17 @@ def cli(ctx: RichContext) -> None: assert res.exit_code == expected_exit_code -def test_group_exit_code_with_context(): +def test_group_exit_code_with_context() -> None: for expected_exit_code in range(10): @group("cli") @pass_context - def cli(ctx: RichContext): + def cli(ctx: RichContext) -> None: ctx.exit(expected_exit_code) @cli.command("subcommand") @pass_context - def subcommand(ctx: RichContext): + def subcommand(ctx: RichContext) -> None: ctx.exit(999) runner = CliRunner() diff --git a/tests/test_help.py b/tests/test_help.py index 0d84ad46..6ac661be 100644 --- a/tests/test_help.py +++ b/tests/test_help.py @@ -1,12 +1,13 @@ -from typing import Any, Callable, Optional, Type +from typing import Any, Callable, Optional, Type, Union import click import pytest from click import UsageError -from conftest import AssertRichFormat, AssertStr, InvokeCli from packaging import version from rich.console import Console +from tests.conftest import AssertRichFormat, AssertStr, InvokeCli + import rich_click.rich_click as rc from rich_click import command, group, pass_context, rich_config, RichContext, RichHelpConfiguration from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X, CLICK_IS_VERSION_80 @@ -202,7 +203,11 @@ ) @pytest.mark.filterwarnings("ignore:^.*click prior to.*$:RuntimeWarning") def test_rich_click( - cmd: str, args: str, error: Optional[Type[Exception]], rich_config, assert_rich_format: AssertRichFormat + cmd: str, + args: str, + error: Optional[Type[Exception]], + rich_config: Optional[Callable[[Any], Union[RichGroup, RichCommand]]], + assert_rich_format: AssertRichFormat, ) -> None: assert_rich_format(cmd, args, error, rich_config) @@ -350,7 +355,7 @@ def test_rich_config_decorator_order( expected_command_type: Type[RichCommand], expected_help_output: str, ) -> None: - @command_callable() + @command_callable() # type: ignore[misc] @rich_config(Console(), RichHelpConfiguration(max_width=60, use_markdown=True)) def cli() -> None: """Some help From 3cb6bd0f1df2154f4ff73eef5f08279647ff95aa Mon Sep 17 00:00:00 2001 From: dwreeves Date: Fri, 6 Oct 2023 20:12:40 -0400 Subject: [PATCH 11/11] reinstate CommandCollection support --- src/rich_click/__init__.py | 2 ++ src/rich_click/cli.py | 3 +- src/rich_click/rich_command.py | 56 +++++++++++++++++++++++----------- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/rich_click/__init__.py b/src/rich_click/__init__.py index f6b40d90..8973b7e0 100644 --- a/src/rich_click/__init__.py +++ b/src/rich_click/__init__.py @@ -12,6 +12,7 @@ # We need to manually import these instead of `from click import *` to force mypy to recognize a few type annotation overrides for the rich_click decorators. from click.core import Argument as Argument from click.core import Command as Command +from click.core import CommandCollection as CommandCollection from click.core import Context as Context from click.core import Group as Group from click.core import Option as Option @@ -76,6 +77,7 @@ from rich_click.decorators import pass_context as pass_context from rich_click.decorators import rich_config as rich_config from rich_click.rich_command import RichCommand as RichCommand +from rich_click.rich_command import RichCommandCollection as RichCommandCollection from rich_click.rich_command import RichGroup as RichGroup from rich_click.rich_context import RichContext as RichContext from rich_click.rich_help_configuration import RichHelpConfiguration as RichHelpConfiguration diff --git a/src/rich_click/cli.py b/src/rich_click/cli.py index f9483c9c..7780ec53 100644 --- a/src/rich_click/cli.py +++ b/src/rich_click/cli.py @@ -19,7 +19,7 @@ from rich_click import command as rich_command from rich_click import group as rich_group -from rich_click import RichCommand, RichGroup, RichMultiCommand +from rich_click import RichCommand, RichCommandCollection, RichGroup, RichMultiCommand from rich_click.rich_click import ( ALIGN_ERRORS_PANEL, ERRORS_PANEL_TITLE, @@ -68,6 +68,7 @@ def patch() -> None: click.command = rich_command click.Group = RichGroup # type: ignore[misc] click.Command = RichCommand # type: ignore[misc] + click.CommandCollection = RichCommandCollection # type: ignore[misc] if "MultiCommand" in dir(click): click.MultiCommand = RichMultiCommand # type: ignore[assignment,misc] diff --git a/src/rich_click/rich_command.py b/src/rich_click/rich_command.py index 481d7839..2518e279 100644 --- a/src/rich_click/rich_command.py +++ b/src/rich_click/rich_command.py @@ -9,7 +9,7 @@ from click.utils import make_str, PacifyFlushWrapper from rich.console import Console -from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X +from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X, CLICK_IS_BEFORE_VERSION_9X from rich_click.rich_click import rich_abort_error, rich_format_error, rich_format_help from rich_click.rich_context import RichContext from rich_click.rich_help_configuration import RichHelpConfiguration @@ -162,23 +162,6 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non rich_format_help(self, ctx, formatter) -with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning, module="click") - - class RichMultiCommand(RichCommand, click.MultiCommand): - """Richly formatted click MultiCommand. - - Inherits click.MultiCommand and overrides help and error methods - to print richly formatted output. - """ - - @wraps(click.MultiCommand.__init__) - def __init__(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def] - """Initialize RichGroup class.""" - click.MultiCommand.__init__(self, *args, **kwargs) - self._register_rich_context_settings_from_callback() - - class RichGroup(RichCommand, click.Group): """Richly formatted click Group. @@ -212,3 +195,40 @@ def command( # (The command_class ClassVar was not added until 8.0.) kwargs.setdefault("cls", self.command_class) return super().command(*args, **kwargs) # type: ignore[no-any-return] + + +if CLICK_IS_BEFORE_VERSION_9X: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning, module="click") + + class RichMultiCommand(RichCommand, click.MultiCommand): + """Richly formatted click MultiCommand. + + Inherits click.MultiCommand and overrides help and error methods + to print richly formatted output. + """ + + @wraps(click.MultiCommand.__init__) + def __init__(self, *args: Any, **kwargs: Any) -> None: # type: ignore[no-untyped-def] + """Initialize RichGroup class.""" + click.MultiCommand.__init__(self, *args, **kwargs) + self._register_rich_context_settings_from_callback() + +else: + + class RichMultiCommand(RichGroup): # type: ignore[no-redef] + """This class is deprecated.""" + + +class RichCommandCollection(RichGroup, click.CommandCollection): + """Richly formatted click CommandCollection. + + Inherits click.CommandCollection and overrides help and error methods + to print richly formatted output. + """ + + @wraps(click.CommandCollection.__init__) + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize RichCommandCollection class.""" + click.CommandCollection.__init__(self, *args, **kwargs) + self._register_rich_context_settings_from_callback()