Skip to content

New blog + v1.8 released!

Today we are very happy to release version 1.8 of rich-click, and along with it - this blog!

New docs website and blog

Until now, rich-click has just had a GitHub repo and all documentation has been in the README. Now, we have full docs! We've fleshed out the documentation from the README, and we'll continue to add to it over time.

Live Style Editor

The coolest addition allowed by this change to full documentation is the Live Style Editor.

We built this to address a problem we noticed, which is that the vast majority of users rely on the default rich-click styles. Although we do think rich-click's defaults are pretty good, it pointed to a potential developer experience issue that so many CLIs relied on the defaults. We hope that the live style editor makes it easier for users to make style changes and to personalize their CLIs. ๐Ÿ˜

Here is an example of a style that Phil made with the style editor:

Phil's Generated Code
import rich_click as click

help_config = click.RichHelpConfiguration(
    style_option="bold cyan",
    style_argument="bold cyan",
    style_command="bold green",
    style_switch="bold green",
    style_metavar="green",
    style_metavar_separator="dim blue",
    style_usage="bold italic magenta",
    style_usage_command="bold yellow",
    style_helptext_first_line="bold italic white",
    style_helptext="dim italic yellow",
    style_option_default="bold yellow",
    style_required_short="bold red",
    style_required_long="bold red",
    style_options_panel_border="italic blue",
    style_commands_panel_border="italic blue"
)

@click.group("my-command")
@click.argument("foo")
@click.option("--bar", "-b", help="Lorem ipsum", show_default="someval")
@click.option("--baz", required=True, help="Choose wisely", type=click.Choice(["a", "b", "c"]))
@click.rich_config(help_config=help_config)
def cli(foo, bar):
    """
    Help text for CLI

    Second line of help text.
    """

@cli.command("subcommand")
def subcommand(foo, bar):
    """Help text for subcommand"""

if __name__ == "__main__":
    cli()

And here's one that Daniel made:

Daniel's Generated Code
import rich_click as click

help_config = click.RichHelpConfiguration(
    style_option="bold magenta",
    style_argument="bold yellow",
    style_command="bold blue",
    style_switch="bold magenta",
    style_metavar="yellow",
    style_metavar_separator="",
    style_usage="bold blue",
    style_usage_command="",
    style_helptext_first_line="bold",
    style_helptext="",
    style_option_default="yellow",
    style_required_short="bold red",
    style_required_long="red",
    style_options_panel_border="magenta",
    style_commands_panel_border="blue"
)

@click.group("my-command")
@click.argument("foo")
@click.option("--bar", "-b", help="Lorem ipsum", show_default="someval")
@click.option("--baz", required=True, help="Choose wisely", type=click.Choice(["a", "b", "c"]))
@click.rich_config(help_config=help_config)
def cli(foo, bar):
    """
    Help text for CLI

    Second line of help text.
    """

@cli.command("subcommand")
def subcommand(foo, bar):
    """Help text for subcommand"""

if __name__ == "__main__":
    cli()

rich-click version 1.8

Generate HTML and SVG with the rich-click CLI

The rich-click CLI now allows for --output svg and --output html to help easily generate outputs for READMEs and docs:

rich-click --output svg path.to.my.cli:main --help

Easier decorator API

The rich_config() decorator API is now easier to use: You can pass a dict into the @click.rich_config() decorator. E.g.:

@click.command
@click.rich_config(help_config={"max_width": 100})
def my_command():
    ...

Additionally, dicts behave a little differently than RichHelpConfiguration when dealing with parents, and this behavior can be useful in some contexts. Basically, the child command uses the parent's help config as a baseline, and only updates the fields specified in the dict. So in the following example, my_group will have style_option="red", and my_subcommand will have both style_option="red" and style_argument="green".

@click.group
@click.rich_config(help_config={"style_option": "red"})
def my_group():
    ...

@my_group.command
@click.rich_config(help_config={"style_argument": "green"})
def my_subcommand():
    ...

This can be a little confusing, so we've defined explicit rules (and enforced them in unit tests) for how the config is resolved (these are also in our docs):

flowchart TD
    A["Did you pass in a @rich_config(help_config=...)?"]
    A --> |Yes| Ayes
    A --> |No| Ano

    Ayes["Was it a dict or a RichHelpConfiguration?"]

    Ayes --> |dict| AyesBdict
    Ayes --> |RichHelpConfiguration| AyesBrhc

    AyesBdict["Is there a 'parent' config?"]

    AyesBdict --> |Yes| AyesBdictCyes
    AyesBdict --> |No| AyesBdictCno

    AyesBdictCyes:::StoppingPoint
    AyesBdictCyes["Merge into the parent config,\nand use that"]

    AyesBdictCno:::StoppingPoint
    AyesBdictCno["Merge into the global config,\nand use that"]

    AyesBrhc:::StoppingPoint
    AyesBrhc["Use the RichHelpConfiguration  object.\n\n(Note: RichHelpConfiguration's\ndefaults are independent of the\nglobal config.)"]

    Ano["Is there a 'parent' config?"]

    Ano --> |Yes| AnoByes
    Ano --> |No| AnoBno

    AnoByes:::StoppingPoint
    AnoByes["Use the parent config"]

    AnoBno:::StoppingPoint
    AnoBno["Use the global config"]

    classDef StoppingPoint font-weight: 600;

More style options

The biggest addition to rich-click's styling options was control over panel styles:

  • STYLE_OPTIONS_PANEL_BOX
  • STYLE_COMMANDS_PANEL_BOX
  • STYLE_ERRORS_PANEL_BOX

Here's a silly example of what this can do:

Code for silly example
import rich_click as click

help_config = click.RichHelpConfiguration(
    style_options_panel_box="ASCII",
    style_commands_panel_box="HEAVY",
)

@click.group("app")
@click.option("--env-file", "-e", type=click.Path(), help=".env file")
@click.rich_config(help_config=help_config)
def cli():
    """
    CLI for `app`

    This `app` lets you do cool things.
    """

@cli.command("db")
def deploy():
    """Database commands for app"""

@cli.command("deploy")
def deploy():
    """Deploy app"""

@cli.command("self")
def self():
    """Manage app"""


if __name__ == "__main__":
    cli()

Here's a simple, stylish, and sleek example that would look great in your app:

Code for stylish example
import rich_click as click

help_config = click.RichHelpConfiguration(
    style_options_panel_box="SIMPLE_HEAD",
    style_options_table_box="SIMPLE_HEAD",
    style_commands_panel_box="SIMPLE_HEAD",
    style_commands_table_box="SIMPLE_HEAD",
    style_options_panel_border="bold",
    options_panel_title="[u]Options[/]",
    use_rich_markup=True,
    style_commands_panel_border="bold",
    commands_panel_title="[u]Commands[/]",
    style_option="green",
    style_usage="",
    style_usage_command="",
    style_argument="green",
    style_switch="dim green",
    style_command="green",
    style_metavar="dim",
)

@click.group("app")
@click.option("--env-file", "-e", type=click.Path(), help=".env file")
@click.rich_config(help_config=help_config)
def cli():
    """
    CLI for `app`

    This `app` lets you do cool things.
    """

@cli.command("db")
def deploy():
    """Database commands for app"""

@cli.command("deploy")
def deploy():
    """Deploy app"""

@cli.command("self")
def self():
    """Manage app"""


if __name__ == "__main__":
    cli()

Improvements to option and command group API

The option groups and command groups are a very powerful and useful feature, but historically they've been tricky to work with. The changes we've made in 1.8 improve on the API in a few substantial ways.

Better command path resolution

First, group keys used to only read from the ctx.command_path. Now they read from the ctx.command_path and the ctx.command.name (or the f"{ctx.parent.command.name} {ctx.command.name}" for subcommands). This should cause fewer headaches for users!

Longer explanation

What this means in practical terms is easier to explain by example. For example, the command path for python hello.py --name world is hello.py, but if you were to set hello as a console script entry point in your pyproject.toml or setup.py, then the command path for hello --name world would become hello.

This causes an annoying issue where running python my_script.py and venv/bin/my_script had different behaviors, and it was also unintuitive that the name of the CLI didn't always work, even when it's not an entry point.

Now, both approaches work! ๐Ÿ˜Š

# cli.py
import rich_click as click

# Pick your poison ๐Ÿงช Both approaches now work!
click.rich_click.COMMAND_GROUPS = {
    "super-cool-tool": [...],  # `ctx.command.name`
    "cli.py": [...]  # `ctx.command_path` when running "python cli.py".
}

@click.command("super-cool-tool")
def main():
    ...

Wildcards

Wildcards now also work, in case you want to globally define options or simply don't want to bother spelling out the whole command path.

import rich_click as click

click.rich_click.COMMAND_GROUPS = {
    "*": [...],
}

@click.command("super-cool-tool")
def main():
    ...

You can also stick wildcards in the front, middle, or end of a key, e.g.:

  • cli sub-command *
  • cli * sub-sub-command
  • * sub-command *
Long example
# foo.py
import rich_click as click

click.rich_click.OPTION_GROUPS = {
    "cli * c": [
        {
            "name": "foo",
            "options": ["--flag1"]
        }
    ]
}

@click.group
def cli(): ...

@cli.group("x")
@click.option("--flag1")
@click.option("--flag2")
def x(flag1, flag2): ...
@x.command("a")
@click.option("--flag1")
@click.option("--flag2")
def xa(flag1, flag2): ...
@x.command("b")
@click.option("--flag1")
@click.option("--flag2")
def xb(flag1, flag2): ...
@x.command("c")
@click.option("--flag1")
@click.option("--flag2")
def xc(flag1, flag2): ...

@cli.group("y")
@click.option("--flag1")
@click.option("--flag2")
def y(flag1, flag2): ...
@y.command("a")
@click.option("--flag1")
@click.option("--flag2")
def ya(flag1, flag2): ...
@y.command("b")
@click.option("--flag1")
@click.option("--flag2")
def yb(flag1, flag2): ...
@y.command("c")
@click.option("--flag1")
@click.option("--flag2")
def yc(flag1, flag2): ...

@cli.group("z")
@click.option("--flag1")
@click.option("--flag2")
def z(flag1, flag2): ...
@z.command("a")
@click.option("--flag1")
@click.option("--flag2")
def za(flag1, flag2): ...
@z.command("b")
@click.option("--flag1")
@click.option("--flag2")
def zb(flag1, flag2): ...
@z.command("c")
@click.option("--flag1")
@click.option("--flag2")
def zc(flag1, flag2): ...

cli()

This works the way you'd expect:

$ python foo.py x c --help

 Usage: foo.py x c [OPTIONS]                                                          

โ•ญโ”€ foo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ --flag1    TEXT                                                                    โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€ Options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ --flag2    TEXT                                                                    โ”‚
โ”‚ --help           Show this message and exit.                                       โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

$ python foo.py x b --help

 Usage: foo.py x b [OPTIONS]                                                           

โ•ญโ”€ Options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ --flag1    TEXT                                                                     โ”‚
โ”‚ --flag2    TEXT                                                                     โ”‚
โ”‚ --help           Show this message and exit.                                        โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

panel_styles + Arguments panel styling

The dicts for command+option groups now accept an optional panel_styles key, which passes kwargs to the Panel().

Another handy feature is that the "Arguments" panel (which is created when the config option show_arguments is True) can now be stylized through the API so long as the following is true:

  1. config.show_arguments is True.
  2. There is an option group with a name equal to the config.arguments_panel_title (default: 'Arguments')
  3. The option group does not have any options (The list is empty, undefined, or None).

Example:

import rich_click as click
from rich import box

help_config = click.RichHelpConfiguration(
    show_arguments=True,
    option_groups={
        "my-command": [
            {
                "name": "Arguments",
                "panel_styles": {"box": box.DOUBLE_EDGE, "border_style": "dim red"}
            }
        ]
    }
)

@click.command("my-command")
@click.argument("foo")
@click.argument("bar")
@click.option("--baz")
@click.rich_config(help_config=help_config)
def cli(foo, bar, baz):
    ...

if __name__ == "__main__":
    cli()

Improved performance

During command execution, rich-click now loads faster and takes up less memory than before:

Info

Python regularly compiles .py files into .pyc files to speed up code execution. The with bytecode metrics measure performance with these .pyc files, and the without bytecode metrics measure performance without them.

We include the code we ran below. The metrics you see above were gathered by running the below script on an old Macbook.

Profiling script

This is the script we used to generate the data in the bar chart.

This script requires uv and gdate.

#!/bin/bash
set -eo pipefail

export VIRTUAL_ENV=.venv_benchmarks
export PY_VERSION=3.12

speed_trials=40
mprof_trials=10

###############################################################################

uv venv "${VIRTUAL_ENV}" --python "${PY_VERSION}"

cat <<EOF > hello_click.py
import click

@click.command()
@click.option("--name", default="World", help="Name to greet.")
def hello(name):
    """Greet someone."""
    print(f"Hello, {name}!")
    if name == "Daniel":
        import time
        time.sleep(0.2)

if __name__ == "__main__":
    hello()
EOF

cat <<EOF > hello_rich_click.py
import rich_click as click

@click.command()
@click.option("--name", default="World", help="Name to greet.")
def hello(name):
    """Greet someone."""
    print(f"Hello, {name}!")
    if name == "Daniel":
        import time
        time.sleep(0.2)

if __name__ == "__main__":
    hello()
EOF

cat <<EOF > hello_argparse.py
import argparse

def main():
    parser = argparse.ArgumentParser(description="Greet someone.")
    parser.add_argument("--name", default="World", help="Name to greet.")
    args = parser.parse_args()
    print(f"Hello, {args.name}!")
    if args.name == "Daniel":
        import time
        time.sleep(0.2)

if __name__ == '__main__':
    main()
EOF

cat <<EOF > hello_typer.py
import typer
from typing_extensions import Annotated

def main(name: Annotated[str, typer.Option(help="Name to greet.")] = "World"):
    """Greet someone."""
    print(f"Hello, {name}!")
    if name == "Daniel":
        import time
        time.sleep(0.3)

if __name__ == '__main__':
    main()
EOF

chmod +x hello_argparse.py
chmod +x hello_click.py
chmod +x hello_rich_click.py
chmod +x hello_typer.py

################################################################################

function get_times {
  total_time=0
  filename="${1}"
  clear_pyc_files="${2}"

  # Run once to compile pyc files
  "${VIRTUAL_ENV}/bin/python" "${filename}" --name Phil >/dev/null

  for (( i=0; i < speed_trials; i++ ))
  do

    if [ "${clear_pyc_files}" = "true" ]; then
      find "${VIRTUAL_ENV}/lib/python${PY_VERSION}/site-packages/" -name '*.pyc' -delete
    fi

    start_time=$(gdate +%s.%N)
    "${VIRTUAL_ENV}/bin/python" "${filename}" --name Phil >/dev/null
    end_time=$(gdate +%s.%N)

    elapsed=$(echo "$end_time - $start_time" | bc)
    total_time=$(echo "$total_time + $elapsed" | bc)

  done

  average_time=$(echo "$total_time / $speed_trials" | bc -l)
  echo "Average time for ${filename} with clear_pyc_files=${clear_pyc_files}: $average_time seconds"

}

function get_mprof {
  total_mib=0
  filename="${1}"
  clear_pyc_files="${2}"

  # Run once to compile pyc files
  "${VIRTUAL_ENV}/bin/python" "${filename}" --name Phil >/dev/null

  for (( i=0; i < mprof_trials; i++ ))
  do

    if [ "${clear_pyc_files}" = "true" ]; then
      find "${VIRTUAL_ENV}/lib/python${PY_VERSION}/site-packages/" -name '*.pyc' -delete
    fi

    "${VIRTUAL_ENV}/bin/mprof" run "${VIRTUAL_ENV}/bin/python" "${filename}" --name Daniel >/dev/null
    output=$("${VIRTUAL_ENV}/bin/mprof" peak)
    mprof_file=$(echo "$output" | grep 'mprofile' | awk '{print $1}')
    memory_usage=$(echo "$output" | grep 'mprofile' | awk '{print $2}' | bc)
    total_time=$(echo "$total_mib + $memory_usage" | bc)
    rm "${mprof_file}"
  done

  average_memory_usage=$(echo "$memory_usage / $mprof_trials" | bc -l)
  echo "Average MiB consumed for ${filename} with clear_pyc_files=${clear_pyc_files}: $memory_usage MiB"

}

################################################################################

# Times

uv pip install --no-binary :all: "rich-click==1.8.0"

get_times hello_argparse.py true
get_times hello_click.py true
get_times hello_rich_click.py true

get_times hello_argparse.py false
get_times hello_click.py false
get_times hello_rich_click.py false

uv pip install --no-binary :all: "rich-click==1.7"

get_times hello_rich_click.py true
get_times hello_rich_click.py false

################################################################################

# Memory profiling

uv pip install memory-profiler
uv pip install --no-binary :all: "rich-click==1.8.0"

get_mprof hello_argparse.py true
get_mprof hello_click.py true
get_mprof hello_rich_click.py true

get_mprof hello_argparse.py false
get_mprof hello_click.py false
get_mprof hello_rich_click.py false

uv pip install --no-binary :all: "rich-click==1.7"

get_mprof hello_rich_click.py true
get_mprof hello_rich_click.py false

We include Typer in our profiling to show a reasonable baseline for a Click wrapper's overhead. Typer is an ambitious and great project that's doing quite a bit under the hood, and it's reasonable to expect it to take a little more time and memory.

Why is rich-click 1.8 more performant? 1.7 left a few free optimizations on the table:

  1. Only import rich when rendering help text.
  2. Use click.__version__ instead of importlib.metadata.version("click") for Click 7 compat.

For the first change, this meant replacing code like this...:

from typing import IO, Optional

from rich.console import Console

from rich_click.rich_help_configuration import RichHelpConfiguration

def create_console(config: RichHelpConfiguration, file: Optional[IO[str]] = None) -> Console:
    console = Console(
        # ...
    )
    return console

...with code like this...:

from typing import TYPE_CHECKING, IO, Optional

from rich_click.rich_help_configuration import RichHelpConfiguration

if TYPE_CHECKING:
    from rich.console import Console

def create_console(config: RichHelpConfiguration, file: Optional[IO[str]] = None) -> "Console":
    from rich.console import Console
    console = Console(
        # ...
    )
    return console

...so that Rich is only loaded when it is needed!

Combined, these two changes account for the performance improvements you see.

Performance isn't everything; if it was, we'd all be using argparse, or we'd abandon Python altogether for Rust. This is also peanuts in the grand scheme of things. In all likelihood, you've spent more time reading this blog post than the cumulative amount of time you'll save by pip install --upgrade-ing your rich-click 1.7 project. (There are other reasons to upgrade to 1.8 than performance, of course!)

So why bother improving rich-click's performance if it's not a big deal? Because we're honored every time someone chooses rich-click for their applications, and we want to pay it back by keeping things as efficient as we reasonably can. Your application is complex and special and all yours. We're excited we get to be a very small part of what you're doing, ๐Ÿซถ and we'll do our best to keep our end of things neat and tidy.

And more...

Under the hood, we've done misc. bugfixes and other small internal improvements+refactors, mostly aimed at composability and customizability for advanced use cases. (This does not impact 99% of users.)

Plans for the future

rich-click is not done evolving; 1.8 is just another step on the journey. We already have tons of things planned out for 1.9, which we want to release by the end of 2024.

Before we get there, we are going to spend a little bit of time rewriting and reorganizing our unit tests and adding coverage requirements.

Thank you once again for using rich-click! โค๏ธโค๏ธโค๏ธโค๏ธโค๏ธ