ewels / rich-click

Format click help output nicely with rich.
https://ewels.github.io/rich-click/
MIT License
583 stars 33 forks source link

Color part of the help massage #192

Closed OriBenHur-akeyless closed 2 months ago

OriBenHur-akeyless commented 2 months ago

I would like to color only part of the helm message using click.style but for some reason, the spacing got broken

The reason I would do such a thing is to outline a note, in the help message

Class Mutex(click.Option):
    def __init__(self, *args, **kwargs):
        self.not_required_if: list = kwargs.pop("not_required_if")

        assert self.not_required_if, "'not_required_if' parameter required"
        kwargs["help"] = f'{kwargs.get("help", "")}, NOTE: Mutually exclusive with: {", ".join(self.not_required_if) if len(self.not_required_if) < 2 else  ", ".join(self.not_required_if[:-1])+ " and " + self.not_required_if[-1]}'
        super(Mutex, self).__init__(*args, **kwargs)

    def handle_parse_result(self, ctx, opts, args):
        current_opt: bool = self.name in opts
        for mutex_opt in self.not_required_if:
            if mutex_opt in opts:
                if current_opt:
                    raise click.UsageError(f'Illegal usage: {str(self.name)} '
                                           f'is mutually exclusive with {str(mutex_opt)}.')
                else:
                    self.required = None
        return super(Mutex, self).handle_parse_result(ctx, opts, args)

result with image

but using click.style

class Mutex(click.Option):
    def __init__(self, *args, **kwargs):
        self.not_required_if: list = kwargs.pop("not_required_if")

        assert self.not_required_if, "'not_required_if' parameter required"
        kwargs["help"] = f'''{kwargs.get("help", "")} {click.style("NOTE:", bold=True, fg="magenta")} {click.style(f'Mutually exclusive with: {", ".join(self.not_required_if) if len(self.not_required_if) < 2 else ", ".join(self.not_required_if[:-1]) + " and " + self.not_required_if[-1]}', fg='magenta')}'''.strip()
        super(Mutex, self).__init__(*args, **kwargs)

    def handle_parse_result(self, ctx, opts, args):
        current_opt: bool = self.name in opts
        for mutex_opt in self.not_required_if:
            if mutex_opt in opts:
                if current_opt:
                    raise click.UsageError(f'Illegal usage: {str(self.name)} '
                                           f'is mutually exclusive with {str(mutex_opt)}.')
                else:
                    self.required = None
        return super(Mutex, self).handle_parse_result(ctx, opts, args)

result with this

image

Is there any way to keep the spacing using click.style or can you please propose another way to achieve that?

dwreeves commented 2 months ago

Hi @OriBenHur-akeyless,

That's an interesting error!

If I had to guess what's happening:


So part of the issue here is, I think this is actually a bug in Rich, not a bug with rich-click.

I would check the Rich open issues and see if this is a known issue.

I understand that, in this specific case, it's unfortunate that Rich may have this bug because click.style() is technically part of the rich-click API. So you'd expect them to kind of work together. I totally understand the motivation for wanting this to be fixed.

I can do a little bit of sleuthing as well, and if there is a quick fix then I can put it in as a patch update, but I will say that I'm not totally prioritizing this, because... (read the next section of this post 👀)


Fear not though, there is a workaround!

image
import rich_click as click

class OriginalMutexOption(click.Option):
    def __init__(self, *args, **kwargs):
        self.not_required_if: list = kwargs.pop("not_required_if")

        assert self.not_required_if, "'not_required_if' parameter required"
        kwargs["help"] = f'''{kwargs.get("help", "")} {click.style("NOTE:", bold=True, fg="magenta")} {click.style(f'Mutually exclusive with: {", ".join(self.not_required_if) if len(self.not_required_if) < 2 else ", ".join(self.not_required_if[:-1]) + " and " + self.not_required_if[-1]}', fg='magenta')}'''.strip()
        super(OriginalMutexOption, self).__init__(*args, **kwargs)

    def handle_parse_result(self, ctx, opts, args):
        current_opt: bool = self.name in opts
        for mutex_opt in self.not_required_if:
            if mutex_opt in opts:
                if current_opt:
                    raise click.UsageError(f'Illegal usage: {str(self.name)} '
                                           f'is mutually exclusive with {str(mutex_opt)}.')
                else:
                    self.required = None
        return super(OriginalMutexOption, self).handle_parse_result(ctx, opts, args)

class UpdatedMutexOption(click.Option):
    def __init__(self, *args, **kwargs):
        self.not_required_if: list = kwargs.pop("not_required_if")

        assert self.not_required_if, "'not_required_if' parameter required"
        kwargs["help"] = f'''{kwargs.get("help", "")} [magenta][bold]NOTE:[/bold] Mutually exclusive with: {", ".join(self.not_required_if) if len(self.not_required_if) < 2 else ", ".join(self.not_required_if[:-1]) + " and " + self.not_required_if[-1]}[/magenta]'''.strip()
        super(UpdatedMutexOption, self).__init__(*args, **kwargs)

    def handle_parse_result(self, ctx, opts, args):
        current_opt: bool = self.name in opts
        for mutex_opt in self.not_required_if:
            if mutex_opt in opts:
                if current_opt:
                    raise click.UsageError(f'Illegal usage: {str(self.name)} '
                                           f'is mutually exclusive with {str(mutex_opt)}.')
                else:
                    self.required = None
        return super(UpdatedMutexOption, self).handle_parse_result(ctx, opts, args)

@click.command
@click.option("--foo", cls=OriginalMutexOption, help="foo option", not_required_if=["a"])
@click.option("--bar", cls=UpdatedMutexOption, help="bar option", not_required_if=["a"])
@click.rich_config(help_config={"use_rich_markup": True})
def cli(foo):
    """my app"""

if __name__ == "__main__":
    cli()

TLDR of the differences:

dwreeves commented 2 months ago

Also, if you don't mind, even if the above fixes your issue, I'd still like to keep this one open. I think it's a genuine problem, even if there's an idiomatic workaround.

OriBenHur-akeyless commented 2 months ago

it's not working I'm afraid, it prints the control signs image

dwreeves commented 2 months ago

Did you include use_rich_markup=True in the config as well? That part is necessary, otherwise it will just render as plain text.

OriBenHur-akeyless commented 2 months ago

it's working

dwreeves commented 2 months ago

@OriBenHur-akeyless You can see in the above example how to make use of it.

Basically, add @click.rich_config(help_config={"use_rich_markup": True}) as a decorator for your command.

@click.command()
@click.rich_config(help_config={"use_rich_markup": True})
def cli():
    ...
    # code goes here
dwreeves commented 2 months ago

Yay, I'm happy to hear it's working! 😄

Again, let's keep this issue open, if you don't mind! This is a genuinely strange behavior, and it's intuitive that you believed it would work without causing an error. Ideally your code would have worked just fine.

dwreeves commented 2 months ago

Fixed via #193. Thanks for flagging this issue; going forward in 1.8, click.style() will work 😄