Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[Feature request] Reuse application console for argparse output #91

Closed
leocencetti opened this issue Sep 26, 2023 · 7 comments · Fixed by #93
Closed

[Feature request] Reuse application console for argparse output #91

leocencetti opened this issue Sep 26, 2023 · 7 comments · Fixed by #93
Labels
rich Concerns rich

Comments

@leocencetti
Copy link

First and foremost, I love the tool, kudos to you.

Is there any out-of-the-box way to reuse the main application console for the argparse output?

I would like to generate SVGs using the save_svg() method of the console, but the RichHelpFormatter instantiates its own rich.Console when instantiated by argparse.

In the meantime, I came up with a not-so-clean solution by injecting my console as a class variable, and using that when instantiating
the formatter:

from rich_argparse import RichHelpFormatter
from rich.console import Console
from rich.theme import ThemeStack, Theme

class CustomHelpFormatter(RichHelpFormatter):
    _CUSTOM_CONSOLE: Console = None
    _CUSTOM_CONSOLE_THEME: ThemeStack = None

    @classmethod
    def set_custom_console(cls, console: Console):
        """Store custom console and backup the theme stack"""
        cls._CUSTOM_CONSOLE = console
        cls._CUSTOM_CONSOLE_THEME = console._thread_locals.theme_stack

    def __init__(self, prog: str, indent_increment: int = 2, max_help_position: int = 24, width: int = None) -> None:
        super().__init__(prog, indent_increment, max_help_position, width)

        if self._CUSTOM_CONSOLE:
            # Init console and apply theme
            self._console = self._CUSTOM_CONSOLE
            self._console._thread_locals.theme_stack = ThemeStack(Theme(self.styles))

    def __del__(self):
        if self._CUSTOM_CONSOLE:
            # Revert theme
            self._CUSTOM_CONSOLE._thread_locals.theme_stack = self._CUSTOM_CONSOLE_THEME
        return super().__del__()

And then use it like this:

import argparse
from rich.console import Console

def main():
    my_console = Console(record=True)

    # Inject my console
    CustomHelpFormatter.set_custom_console(my_console)

    # Set parser formatter
    parser = argparse.ArgumentParser(formatter_class=CustomHelpFormatter)

    # Do stuff
    ... 

    # Save SVG
    my_console.save_svg('console.svg')
@hamdanal
Copy link
Owner

I think someone asked for this before (or something similar).

I don't oppose adding a way for the user of the library to generate an svg out of the help text (and maybe other formats available in rich). I don't know how to this correctly though.

In your example, you do self._console._thread_locals.theme_stack = ThemeStack(Theme(self.styles)) which I wouldn't use as a generic solution. In your case it is your project and you know what you're doing but I cannot do use this in a library that doesn't know about how it will be used. I particularly have a problem with:

  1. using a private attribute of the console (_thread_locals): this is not meant to be used outside of rich
  2. modifying the console object that gets passed in. What if the user were using a custom crafted theme on the console they pass to the formatter? I surely cannot just rip it off and replace with my own theme.

Please let me know if you have ideas on how we can have something that works seamlessly.
Also I'd be interested in seeing a full example of your intended usage, maybe something that I can run, and the expected result (to help me perhaps better understand what needs to be done).

@hamdanal hamdanal added help wanted Extra attention is needed rich Concerns rich labels Sep 26, 2023
@leocencetti
Copy link
Author

leocencetti commented Sep 27, 2023

In your example, you do self._console._thread_locals.theme_stack = ThemeStack(Theme(self.styles)) which I wouldn't use as a generic solution. In your case it is your project and you know what you're doing but I cannot do use this in a library that doesn't know about how it will be used.

I spent some more time looking into the console interface and found out that it provides a push_theme() to add a theme to the top of the stack, a pop_theme() to remove it from the stack (restoring the previous one), and even a use_theme() to temporarily use a different theme. All these could avoid manually injecting the ThemeStack.

modifying the console object that gets passed in. What if the user were using a custom crafted theme on the console they pass to the formatter? I surely cannot just rip it off and replace with my own theme.

Using the aforementioned API it should be possible to overlay the rich-argparse theme without breaking the existing one.

Here is an example that puts into practice what I just described and addresses both of your concerns. It uses the push_theme() and pop_theme(): there should also be a way to use the context manager returned by use_theme() but probably you'd have to manually call the __enter__ and __exit__ (or use something like maybe contextlib).

You should be able to just run it and get a console.svg file as shown:

python example.py

console

python example.py --help

console

Edit: I did not test the behavior with an existing console theme, but should be very simple to test and validate...

Complete (revised) example

# example.py
import argparse
from sys import exit

from rich.console import Console
from rich.theme import Theme
from rich_argparse import RichHelpFormatter


class CustomHelpFormatter(RichHelpFormatter):
    _CUSTOM_CONSOLE: Console = None
    _THEME_PUSHED: bool = False

    @classmethod
    def set_custom_console(cls, console: Console):
        """Store custom console and backup the theme stack"""
        cls._CUSTOM_CONSOLE = console

    def __init__(
        self,
        prog: str,
        indent_increment: int = 2,
        max_help_position: int = 24,
        width: int = None,
    ) -> None:
        super().__init__(prog, indent_increment, max_help_position, width)

        if self._CUSTOM_CONSOLE:
            # Init console
            self._console = self._CUSTOM_CONSOLE

            # apply theme (class variable prevents multiple pushes when reused)
            if not CustomHelpFormatter._THEME_PUSHED:
                self._console.push_theme(Theme(self.styles))
                CustomHelpFormatter._THEME_PUSHED = True

    def __del__(self):
        if self._CUSTOM_CONSOLE:
            # Revert theme
            if CustomHelpFormatter._THEME_PUSHED:
                CustomHelpFormatter._THEME_PUSHED = False
                self._console.pop_theme()
        return super().__del__()


class MyApplication:
    def __init__(self, record: bool = True) -> None:
        self.console = Console(record=record)
        self._record = record

    def _get_args(self) -> argparse.Namespace:
        """Configure parser and get CLI args"""

        CustomHelpFormatter.set_custom_console(self.console)

        parser = argparse.ArgumentParser(formatter_class=CustomHelpFormatter)
        parser.add_argument("--flag-1", action="store_true", help="Set flag 1 to true")
        parser.add_argument("--flag-2", action="store_true", help="Set flag 2 to true")
        parser.add_argument("--flag-3", action="store_true", help="Set flag 3 to true")

        # Override exit method
        parser.exit = self._on_parser_exit

        return parser.parse_args()

    def _on_parser_exit(self, status=0, message=None) -> None:
        """Custom parser exit that adds step to save the console"""
        if message:
            self.console.print(message)
        if self._record:
            self.console.save_svg("console.svg")
        exit(status)

    def run(self) -> None:
        """Main application entrypoint"""
        args = self._get_args()

        self.console.print("[green]Hello![/]")

        self.console.print(f"Flag 1 set to: {args.flag_1}")
        self.console.print(f"Flag 2 set to: {args.flag_2}")
        self.console.print(f"Flag 3 set to: {args.flag_3}")

        if self._record:
            self.console.save_svg("console.svg")


if __name__ == "__main__":
    app = MyApplication()
    app.run()

@hamdanal
Copy link
Owner

This is very useful, thank you.

I'll start by allowing the console to be passed to the constructor of the formatter and see what we can come up for the SVG generation. This allows you to at least avoid the hack with the class variable.

Note that when I run your example I get a traceback:

With `python example.py`
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f070e0a52d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f070e0a52d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f070e0a52d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'
Hello!
Flag 1 set to: False
Flag 2 set to: False
Flag 3 set to: False
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f070e0a52d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'
With `python example.py --help`
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f2248f752d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f2248f752d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f2248f752d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'
Usage: example.py [-h] [--flag-1] [--flag-2] [--flag-3]

Options:
  -h, --help  show this help message and exit
  --flag-1    Set flag 1 to true
  --flag-2    Set flag 2 to true
  --flag-3    Set flag 3 to true
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f2248f752d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'
Exception ignored in: <function CustomHelpFormatter.__del__ at 0x7f2248f752d0>
Traceback (most recent call last):
  File "/tmp/t/example.py", line 43, in __del__
    return super().__del__()
AttributeError: 'super' object has no attribute '__del__'

Also I noticed you generate the SVG in both cases above. Is this intentional? What is your use case? I think the previous request I got for this feature was to include the SVG in the documentation; in this case the SVG should not be generated when your users run the application but rather with a special option or an env var. I am asking because this will affect how I will introduce the feature in rich-argparse.

hamdanal added a commit that referenced this issue Sep 30, 2023
hamdanal added a commit that referenced this issue Sep 30, 2023
@hamdanal
Copy link
Owner

hamdanal commented Oct 1, 2023

OK so I have created something :)
In #93, I added an action that when used generates an SVG, HTML, or text file with the formatter's output. Using this action, your example can be reduced to something like:

import argparse
from rich.console import Console
from rich_argparse import HelpPreviewAction, RichHelpFormatter

class MyApplication:
    def __init__(self):
        self.console = Console()

    def _get_args(self) -> argparse.Namespace:
        """Configure parser and get CLI args"""
        parser = argparse.ArgumentParser(
            formatter_class=lambda prog: RichHelpFormatter(prog, console=self.console)
        )
        parser.add_argument("--flag-1", action="store_true", help="Set flag 1 to true")
        parser.add_argument("--flag-2", action="store_true", help="Set flag 2 to true")
        parser.add_argument("--flag-3", action="store_true", help="Set flag 3 to true")
        parser.add_argument(
            "--my-secret-help-preview-generator", action=HelpPreviewAction, console=self.console
        )
        return parser.parse_args()

    def run(self) -> None:
        """Main application entrypoint"""
        args = self._get_args()

        self.console.print("[green]Hello![/]")

        self.console.print(f"Flag 1 set to: {args.flag_1}")
        self.console.print(f"Flag 2 set to: {args.flag_2}")
        self.console.print(f"Flag 3 set to: {args.flag_3}")

if __name__ == "__main__":
    app = MyApplication()
    app.run()

Then using the script like this:

  • python example.py: runs the application does not print help, does not generate SVG.
  • python example.py --help: prints the help and exits (normal help behavior), does not generate SVG.
  • python example.py --my-secret-help-preview-generator console.svg: generates a console.svg file and exits, does not run the application, does not print the help.
    This option is hidden by default so that it does not appear in the help or SVG file.

If you want to generate an HTML instead, simply replace console.svg by a file with .html suffix.

You can also pass keyword arguments to the save_... method using the save_kwds parameter like so

parser.add_argument(
    "--generate-whatever",
    action=HelpPreviewAction,
    console=self.console,
    save_kwds={"theme": DIMMED_MONOKAI, "title": "My Title"},
)

You can also set the default output file using path:

parser.add_argument("--generate-whatever", action=HelpPreviewAction, console=self.console, path="console.svg")

then you can simply call the script with python example.py --generate-whatever

@leocencetti
Copy link
Author

leocencetti commented Oct 2, 2023

I'll start by allowing the console to be passed to the constructor of the formatter and see what we can come up for the SVG generation. This allows you to at least avoid the hack with the class variable.

Great, I've seen the changes in #92 and it looks promising.

Note that when I run your example I get a traceback:

Right... I added the super().__del__() for completeness, but indeed neither RichHelpFormatter nor argparser.HelpFormatter provide one. Removing it should work just fine.

Also I noticed you generate the SVG in both cases above. Is this intentional? What is your use case? I think the previous request I got for this feature was to include the SVG in the documentation; in this case the SVG should not be generated when your users run the application but rather with a special option or an env var. I am asking because this will affect how I will introduce the feature in rich-argparse.

Yes, this is intentional. The use case, as per the previous request, is for documentation only. I simplified a lot the example for brevity's sake, but indeed I am using a special option for it.

Regarding your proposal for #93, it seems like it should do the job for my use case. I am personally not a huge fan of the lambda as the formatter_class but it gets the job done (and there aren't many alternatives).

Do you have a timeline for when this (and previous) changes might make it to release? Just to understand whether I should wait or just install from source for the moment...

@hamdanal
Copy link
Owner

hamdanal commented Oct 7, 2023

I changed the implementation of the action to not take a console. You no longer need to use the lambda. This is also simpler and less error prone, we can always revisit in the future if someone needs a custom console in the action.

Could you please test if the main branch works for your case? You can find the docs here.

Do you have a timeline for when this (and previous) changes might make it to release? Just to understand whether I should wait or just install from source for the moment...

Soonish. hopefully by next week if everything goes well.

@leocencetti
Copy link
Author

I changed the implementation of the action to not take a console. You no longer need to use the lambda. This is also simpler and less error prone, we can always revisit in the future if someone needs a custom console in the action.

Could you please test if the main branch works for your case? You can find the docs here.

Tested and works. The only downside of not being able to pass a console (for my specific use case) is that anything that is printed before the help output is not recorded (in my case I am printing a title banner/graphic for the CLI tool). But it's a minor-ish issue.

Thank you for the fix!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
rich Concerns rich
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants