Repository: ewels/rich-click

Issue:
Use a RichCommand for the actual rich-click CLI tool + add formatting options
Right now the `cli.py` uses a custom CLI implementation, I believe for historic reasons. I _think_ this can be refactored into being a `RichCommand`.

There are two main benefits of this.

First: simpler, more idiomatic, and more testable code. Testability is the most important sub-bullet here. Right now `cli.py` is not covered by tests. See #124. This lack of test coverage of `cli.py` has caused multiple regressions over the last couple months; see #142 and #152.

Second: it would be to allow for things like passing of configuration flags, which can allow users who are aliasing `rich-click` to set options such as color themes. For example:

```bash
alias foo="rich-click --rich-config '{"\style_option\": \"red\", \"width\": 100}' foo"
foo bar
```

In the above example, the user sets some command in their Python environment named `foo` to an alias that invokes `rich-click` with custom style options.

There may be other options and flags that are of interest here; that's just the first that comes to mind.

---

This is doable and theoretically simple since Click allows for arbitrary passing of args to a command:

```python
# foo.py
import click

@click.command(name='my-cmd', context_settings=dict(
    ignore_unknown_options=True,
    allow_extra_args=True,
))
@click.option('--option1')
@click.option('--option2')
@click.pass_context
def cli(ctx, option1, option2):
    print(ctx.args)

cli()
```

```shell
>>> python foo.py --option1 a --option2 b c d e --f g
['c', 'd', 'e', '--f', 'g']
```

Discussion:

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 5ee07fa..d138c05 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,7 @@ exclude = [
     "venv",
     "build",
     "sdist",
-    "tests/**",
+    "tests/fixtures/**",
     "examples/**",
 ]
 ignore = [
diff --git a/src/rich_click/cli.py b/src/rich_click/cli.py
index ab7c2e0..f8e27b9 100644
--- a/src/rich_click/cli.py
+++ b/src/rich_click/cli.py
@@ -1,9 +1,9 @@
 """The command line interface."""
 
 import sys
+from gettext import gettext as _
 from importlib import import_module
-from textwrap import dedent
-from typing import Any, List, Optional
+from typing import List, Optional, Union
 
 
 try:
@@ -13,58 +13,16 @@ except ImportError:
     import importlib_metadata as metadata  # type: ignore[no-redef,import-not-found,unused-ignore]
 
 import click
-from rich.console import Console
-from rich.padding import Padding
-from rich.panel import Panel
-from rich.text import Text
 
 from rich_click.decorators import command as rich_command
 from rich_click.decorators import group as rich_group
-from rich_click.rich_click import (
-    ALIGN_ERRORS_PANEL,
-    ERRORS_PANEL_TITLE,
-    STYLE_ERRORS_PANEL_BORDER,
-    STYLE_HELPTEXT,
-    STYLE_HELPTEXT_FIRST_LINE,
-    STYLE_USAGE,
-    STYLE_USAGE_COMMAND,
-)
+from rich_click.decorators import pass_context, rich_config
 from rich_click.rich_command import RichCommand, RichCommandCollection, RichGroup, RichMultiCommand
+from rich_click.rich_context import RichContext
+from rich_click.rich_help_configuration import RichHelpConfiguration
 
 
-console = Console()
-
-
-def _print_usage() -> None:
-    console.print(
-        Padding(
-            Text.from_markup(f"[{STYLE_USAGE}]Usage:[/] rich-click [SCRIPT | MODULE:FUNCTION] [-- SCRIPT_ARGS...]"),
-            1,
-        ),
-        style=STYLE_USAGE_COMMAND,
-    )
-
-
-def _print_help() -> None:
-    help_paragraphs = dedent(main.__doc__ or "").split("\n\n")
-    help_paragraphs = [x.replace("\n", " ").strip() for x in help_paragraphs]
-    console.print(
-        Padding(
-            Text.from_markup(help_paragraphs[0].strip()),
-            (0, 1),
-        ),
-        style=STYLE_HELPTEXT_FIRST_LINE,
-    )
-    console.print(
-        Padding(
-            Text.from_markup("\n\n".join(help_paragraphs[1:]).strip()),
-            (0, 1),
-        ),
-        style=STYLE_HELPTEXT,
-    )
-
-
-def patch() -> None:
+def patch(rich_config: Optional[RichHelpConfiguration] = None) -> None:
     """Patch Click internals to use Rich-Click types."""
     click.group = rich_group
     click.command = rich_command
@@ -73,6 +31,8 @@ def patch() -> None:
     click.CommandCollection = RichCommandCollection  # type: ignore[misc]
     if "MultiCommand" in dir(click):
         click.MultiCommand = RichMultiCommand  # type: ignore[assignment,misc,unused-ignore]
+    if rich_config is not None:
+        rich_config._dump_into_globals()
 
 
 def entry_points(*, group: str) -> "metadata.EntryPoints":  # type: ignore[name-defined]
@@ -88,7 +48,78 @@ def entry_points(*, group: str) -> "metadata.EntryPoints":  # type: ignore[name-
     return epg.get(group, [])
 
 
-def main(args: Optional[List[str]] = None) -> Any:
+class _RichHelpConfigurationParamType(click.ParamType):
+
+    name = "JSON"
+
+    def __repr__(self) -> str:
+        return "JSON"
+
+    def convert(
+        self,
+        value: Optional[Union[RichHelpConfiguration, str]],
+        param: Optional[click.Parameter],
+        ctx: Optional[click.Context],
+    ) -> Optional[RichHelpConfiguration]:
+
+        if value is None or isinstance(value, RichHelpConfiguration):
+            return value
+        else:
+            try:
+                import json
+
+                if value.startswith("@"):
+                    with open(value[1:], "r") as f:
+                        data = json.load(f)
+                else:
+                    data = json.loads(value)
+                if not isinstance(data, dict):
+                    raise ValueError("--rich-config needs to be a JSON.")
+                return RichHelpConfiguration.load_from_globals(**data)
+            except Exception as e:
+                # In normal circumstances, a bad arg to a CLI doesn't
+                # prevent the help text from rendering.
+                if ctx is not None and ctx.params.get("show_help", False):
+                    click.echo(ctx.get_help(), color=ctx.color)
+                    ctx.exit()
+                else:
+                    raise e
+
+
+@rich_command("rich-click", context_settings=dict(allow_interspersed_args=False, help_option_names=[]))
+@click.argument("script_and_args", nargs=-1, metavar="[SCRIPT | MODULE:CLICK_COMMAND] [-- SCRIPT_ARGS...]")
+@click.option(
+    "--rich-config",
+    type=_RichHelpConfigurationParamType(),
+    help="Keyword arguments to pass into the [de]RichHelpConfiguration()[/] used"
+    " to render the help text of the command. You can pass either a JSON directly, or a file"
+    " prefixed with `@` (for example: '@rich_config.json'). Note that the --rich-config"
+    " option is also used to render this help text you're reading right now!",
+)
+@click.option(
+    # The rich-click CLI uses a special implementation of --help,
+    # which is aware of the --rich-config object.
+    "--help",
+    "-h",
+    "show_help",
+    is_eager=True,
+    is_flag=True,
+    help=_("Show this message and exit."),
+)
+@pass_context
+@rich_config(
+    help_config={
+        "use_markdown": False,
+        "use_rich_markup": True,
+        "errors_epilogue": "[d]Please run [yellow bold]rich-click --help[/] for usage information.[/]",
+    }
+)
+def main(
+    ctx: RichContext,
+    script_and_args: List[str],
+    rich_config: Optional[RichHelpConfiguration],
+    show_help: bool,
+) -> None:
     """
     The [link=https://github.com/ewels/rich-click]rich-click[/] CLI provides attractive help output from any
     tool using [link=https://click.palletsprojects.com/]click[/], formatted with
@@ -97,60 +128,42 @@ def main(args: Optional[List[str]] = None) -> Any:
     The rich-click command line tool can be prepended before any Python package
     using native click to provide attractive richified click help output.
 
-    For example, if you have a package called [blue]my_package[/] that uses click,
+    For example, if you have a package called [argument]my_package[/] that uses click,
     you can run:
 
-    [blue]  rich-click my_package --help  [/]
+    >>> [command]rich-click[/] [argument]my_package[/] [option]--help[/]
 
-    It only works if the package is using vanilla click without customised [cyan]group()[/]
-    or [cyan]command()[/] classes.
+    This does not always work if the package is using customised [b]click.group()[/]
+    or [b]click.command()[/] classes.
     If in doubt, please suggest to the authors that they use rich_click within their
     tool natively - this will always give a better experience.
     """  # noqa: D400, D401
-    args = args or sys.argv[1:]
-    if not args or args == ["--help"]:
-        # Print usage if we got no args, or only --help
-        _print_usage()
-        _print_help()
-        sys.exit(0)
-    else:
-        script_name = args[0]
+    if (show_help or not script_and_args) and not ctx.resilient_parsing:
+        if rich_config is not None:
+            rich_config.use_markdown = False
+            rich_config.use_rich_markup = True
+            ctx.help_config = rich_config
+        click.echo(ctx.get_help(), color=ctx.color)
+        ctx.exit()
+
+    script, *args = script_and_args
+
     scripts = {script.name: script for script in entry_points(group="console_scripts")}
-    if script_name in scripts:
-        # a valid script was passed
-        script = scripts[script_name]
-        module_path, function_name = script.value.split(":", 1)
-        prog = script_name
-    elif ":" in script_name:
+    if script in scripts:
+        module_path, function_name = scripts[script].value.split(":", 1)
+    elif ":" in script:
         # the path to a function was passed
-        module_path, function_name = args[0].split(":", 1)
-        prog = module_path.split(".", 1)[0]
+        module_path, function_name = script.split(":", 1)
     else:
-        _print_usage()
-        console.print(
-            Panel(
-                Text.from_markup(f"No such script: [bold]{script_name}[/]"),
-                border_style=STYLE_ERRORS_PANEL_BORDER,
-                title=ERRORS_PANEL_TITLE,
-                title_align=ALIGN_ERRORS_PANEL,
-            )
-        )
-        console.print(
-            Padding(
-                "Please run [yellow bold]rich-click --help[/] for usage information.",
-                (0, 1),
-            ),
-            style="dim",
-        )
-        sys.exit(1)
-    if len(args) > 1:
-        if args[1] == "--":
-            del args[1]
-    sys.argv = [prog, *args[1:]]
+        raise click.ClickException(f"No such script: {script_and_args[0]}")
+
+    prog = module_path.split(".", 1)[0]
+
+    sys.argv = [prog, *args]
     # patch click before importing the program function
-    patch()
+    patch(rich_config=rich_config)
     # import the program function
     module = import_module(module_path)
     function = getattr(module, function_name)
     # simply run it: it should be patched as well
-    return function()
+    function()
diff --git a/src/rich_click/rich_command.py b/src/rich_click/rich_command.py
index 62f2da8..872cd5a 100644
--- a/src/rich_click/rich_command.py
+++ b/src/rich_click/rich_command.py
@@ -77,6 +77,23 @@ class RichCommand(click.Command):
             return RichHelpConfiguration(**cfg)
         return cfg
 
+    def _generate_rich_help_config(self) -> RichHelpConfiguration:
+        """
+        Use for error handling when a Context is not available.
+
+        If the Context is available, then the help configuration in the Context
+        should be preferred.
+        """
+        cfg = self.context_settings.get("rich_help_config", {})
+        try:
+            if isinstance(cfg, Mapping):
+                return RichHelpConfiguration(**cfg)
+            elif isinstance(cfg, RichHelpConfiguration):
+                return cfg
+        except Exception as e:
+            click.echo(f"{e.__class__.__name__}{e.args}", file=sys.stderr)
+        return RichHelpConfiguration()
+
     def main(
         self,
         args: Optional[Sequence[str]] = None,
@@ -123,6 +140,8 @@ class RichCommand(click.Command):
         else:
             self._main_shell_completion(extra, prog_name, complete_var)
 
+        ctx = None
+
         try:
             try:
                 with self.make_context(prog_name, args, **extra) as ctx:
@@ -143,7 +162,11 @@ class RichCommand(click.Command):
             except click.exceptions.ClickException as e:
                 if not standalone_mode:
                     raise
-                formatter = self.context_class.formatter_class(config=ctx.help_config, file=sys.stderr)
+                if ctx is not None:
+                    config = ctx.help_config
+                else:
+                    config = self._generate_rich_help_config()
+                formatter = self.context_class.formatter_class(config=config, file=sys.stderr)
                 from rich_click.rich_help_rendering import rich_format_error
 
                 rich_format_error(e, formatter)
@@ -164,7 +187,11 @@ class RichCommand(click.Command):
             if not standalone_mode:
                 raise
             try:
-                formatter = self.context_class.formatter_class(config=ctx.help_config)
+                if ctx is not None:
+                    config = ctx.help_config
+                else:
+                    config = self._generate_rich_help_config()
+                formatter = self.context_class.formatter_class(config=config)
             except Exception:
                 click.echo("Aborted!", file=sys.stderr)
             else:
diff --git a/src/rich_click/rich_help_configuration.py b/src/rich_click/rich_help_configuration.py
index 40a08b1..47c1daa 100644
--- a/src/rich_click/rich_help_configuration.py
+++ b/src/rich_click/rich_help_configuration.py
@@ -205,6 +205,16 @@ class RichHelpConfiguration:
         inst = cls(**kw)
         return inst
 
+    def _dump_into_globals(self, module: Optional[ModuleType] = None) -> None:
+        if module is None:
+            import rich_click.rich_click as rc
+
+            module = rc
+        for k, v in self.__dataclass_fields__.items():
+            if v.init:
+                if hasattr(module, k.upper()):
+                    setattr(module, k.upper(), getattr(self, k))
+
 
 def __getattr__(name: str) -> Any:
     if name == "OptionHighlighter":


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29..8dbaf7e 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,1 @@
+# ruff: noqa: D104
diff --git a/tests/conftest.py b/tests/conftest.py
index 9581368..c8657c2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,4 @@
-# flake8: noqa D*
+# ruff: noqa: D101,D103,D401
 import importlib
 import json
 import os
@@ -7,16 +7,15 @@ from dataclasses import asdict
 from importlib import reload
 from pathlib import Path
 from types import ModuleType
-from typing import Any, Callable, cast, Dict, Optional, Type, Union
+from typing import Any, Callable, Dict, Optional, Type, Union, cast
 
 import click
 import pytest
-from click.testing import CliRunner, Result
-from typing_extensions import Protocol
-
 import rich_click.rich_click as rc
+from click.testing import CliRunner, Result
 from rich_click.rich_command import RichCommand, RichGroup
 from rich_click.rich_help_configuration import RichHelpConfiguration
+from typing_extensions import Protocol
 
 
 @pytest.fixture
@@ -42,7 +41,7 @@ def click_major_version() -> int:
 class AssertStr(Protocol):
     def __call__(self, actual: str, expectation: Union[str, Path]) -> None:
         """
-        Assert strings by normalizining line endings
+        Assert strings by normalizining line endings.
 
         Args:
         ----
@@ -138,10 +137,6 @@ class InvokeCli(Protocol):
 
         Small convenience fixture to allow invoking a click Command
         without standalone mode.
-
-        Args:
-        ----
-            cmd: Click Command
         """
         ...
 
@@ -160,7 +155,7 @@ class AssertRichFormat(Protocol):
         rich_config: Optional[Callable[[Any], Union[RichGroup, RichCommand]]],
     ) -> None:
         """
-        Invokes the cli command and applies assertions against the results
+        Invokes the cli command and applies assertions against the results.
 
         This command resolves the cli application from the fixtures directory dynamically
         to isolate module configuration state between tests. It will also assert that
diff --git a/tests/test_config.py b/tests/test_config.py
index bfc3f4f..8adf09f 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,12 +1,13 @@
+# ruff: noqa: D101,D103,D401
+import json
+from dataclasses import asdict
 from typing import TYPE_CHECKING
 
 import pytest
-
-from rich_click import group, command, rich_config, RichHelpConfiguration, RichContext
-from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X
 import rich_click.rich_click as rc
-from dataclasses import asdict
-import json
+from rich_click import RichContext, RichHelpConfiguration, command, group, rich_config
+from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X
+
 
 if CLICK_IS_BEFORE_VERSION_8X:
     pytest.skip(reason="rich_config not supported for click < 8.", allow_module_level=True)
diff --git a/tests/test_exit_code.py b/tests/test_exit_code.py
index f7a364b..038d473 100644
--- a/tests/test_exit_code.py
+++ b/tests/test_exit_code.py
@@ -1,12 +1,13 @@
+# ruff: noqa: D101,D103,D401
 import sys
 
 import click
 import pytest
 from click.testing import CliRunner
-
-from rich_click import command, group, pass_context, RichContext
+from rich_click import RichContext, command, group, pass_context
 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.
 
 
diff --git a/tests/test_help.py b/tests/test_help.py
index 9d4678d..48fc3ee 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -1,19 +1,20 @@
+# ruff: noqa: D101,D103,D401
 from typing import Any, Callable, Optional, Type, Union
 
 import click
 import pytest
+import rich_click.rich_click as rc
 from click import UsageError
+from click.testing import CliRunner
 from packaging import version
 from rich.console import Console
-from click.testing import CliRunner
-
-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 import RichContext, RichHelpConfiguration, command, group, pass_context, rich_config
 from rich_click._compat_click import CLICK_IS_BEFORE_VERSION_8X, CLICK_IS_VERSION_80
 from rich_click.rich_command import RichCommand, RichGroup
 
+from tests.conftest import AssertRichFormat, AssertStr
+
+
 try:
     from importlib import metadata  # type: ignore[import,unused-ignore]
 except ImportError:
@@ -230,7 +231,7 @@ if rich_version.major == 12:
     command_help_output = """
  Usage: cli [OPTIONS]
 
- Some help
+ Some help.
  ╔════════════════════════════════════════════════════════╗
  ║                         Header                         ║
  ╚════════════════════════════════════════════════════════╝
@@ -242,7 +243,7 @@ if rich_version.major == 12:
     group_help_output = """
  Usage: cli [OPTIONS] COMMAND [ARGS]...
 
- Some help
+ Some help.
  ╔════════════════════════════════════════════════════════╗
  ║                         Header                         ║
  ╚════════════════════════════════════════════════════════╝
@@ -255,7 +256,7 @@ else:
     command_help_output = """
  Usage: cli [OPTIONS]
 
- Some help
+ Some help.
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃                         Header                         ┃
  ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
@@ -267,7 +268,7 @@ else:
     group_help_output = """
  Usage: cli [OPTIONS] COMMAND [ARGS]...
 
- Some help
+ Some help.
  ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
  ┃                         Header                         ┃
  ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
@@ -370,7 +371,7 @@ def test_rich_config_decorator_order(
     )
     def cli() -> None:
         """
-        Some help
+        Some help.
 
         # Header
         """
@@ -383,7 +384,7 @@ def test_rich_config_decorator_order(
     assert_str(
         cli.__doc__,
         """
-    Some help
+    Some help.
 
     # Header
     """,
@@ -403,7 +404,7 @@ def test_rich_config_max_width(cli_runner: CliRunner, assert_str: AssertStr) ->
 
     @command()
     def cli() -> None:
-        """Some help text"""
+        """Some help text."""
         pass
 
     result = cli_runner.invoke(cli, "--help")
@@ -413,7 +414,7 @@ def test_rich_config_max_width(cli_runner: CliRunner, assert_str: AssertStr) ->
         """
 Usage: cli [OPTIONS]
 
- Some help text
+ Some help text.
 
 ╭─ Options ────────────────────────────────────────────────────╮
 │ --help      Show this message and exit.                      │
diff --git a/tests/test_rich_click_cli.py b/tests/test_rich_click_cli.py
new file mode 100644
index 0000000..dab6888
--- /dev/null
+++ b/tests/test_rich_click_cli.py
@@ -0,0 +1,104 @@
+# ruff: noqa: D101,D103,D401
+import sys
+from inspect import cleandoc
+from pathlib import Path
+from typing import List
+
+import pytest
+import rich_click.rich_click as rc
+from click.testing import CliRunner
+from pytest import MonkeyPatch
+from rich_click.cli import main
+from rich_click.rich_context import RichContext
+
+from tests.conftest import AssertStr
+
+
+@pytest.fixture(autouse=True)
+def default_config(initialize_rich_click: None) -> None:
+    # Default config settings from https://github.com/Textualize/rich/blob/master/tests/render.py
+    rc.WIDTH = 100
+    rc.COLOR_SYSTEM = None
+    rc.FORCE_TERMINAL = True
+
+
+@pytest.fixture
+def simple_script(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
+    path = tmp_path / "scripts"
+    path.mkdir()
+    f = cleandoc(
+        '''
+        import click
+
+        @click.command
+        def cli():
+            """My help text"""
+            print('Hello, world!')
+
+        cli()
+        '''
+    )
+    py_script = path / "mymodule.py"
+    py_script.write_text(f)
+
+    monkeypatch.setattr(sys, "path", [path.as_posix(), *sys.path.copy()])
+    monkeypatch.setattr(RichContext, "command_path", "mymodule")
+
+    return
+
+
+@pytest.mark.parametrize(
+    "command",
+    [
+        ["mymodule:cli", "--help"],
+        ["--", "mymodule:cli", "--help"],
+    ],
+)
+def test_simple_rich_click_cli(
+    simple_script: None, cli_runner: CliRunner, assert_str: AssertStr, command: List[str]
+) -> None:
+    res = cli_runner.invoke(main, command)
+
+    expected_output = """
+ Usage: mymodule [OPTIONS]
+
+ My help text
+
+╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮
+│ --help      Show this message and exit.                                                          │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"""
+
+    assert_str(actual=res.stdout, expectation=expected_output)
+
+
+@pytest.mark.parametrize(
+    "command",
+    [
+        ["mymodule:cli"],
+        ["--", "mymodule:cli"],
+        ["mymodule:cli", "--"],
+    ],
+)
+def test_simple_rich_click_cli_execute_command(
+    simple_script: None, cli_runner: CliRunner, assert_str: AssertStr, command: List[str]
+) -> None:
+    res = cli_runner.invoke(main, command)
+
+    assert res.stdout == "Hello, world!\n"
+
+
+def test_custom_config_rich_click_cli(simple_script: None, cli_runner: CliRunner, assert_str: AssertStr) -> None:
+    res = cli_runner.invoke(main, ["--rich-config", '{"options_panel_title": "Custom Name"}', "mymodule:cli", "--help"])
+
+    expected_output = """
+ Usage: mymodule [OPTIONS]
+
+ My help text
+
+╭─ Custom Name ────────────────────────────────────────────────────────────────────────────────────╮
+│ --help      Show this message and exit.                                                          │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+"""
+
+    assert_str(actual=res.stdout, expectation=expected_output)