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

Generate commands documentation #649

Open
coccoinomane opened this issue Dec 19, 2022 · 11 comments
Open

Generate commands documentation #649

coccoinomane opened this issue Dec 19, 2022 · 11 comments

Comments

@coccoinomane
Copy link

Hello,

First of all, thank you so much @derks for developing and maintaning cement! I have been using it intensely for a web3 project of mine and I am loving it 💪

That said, I was wondering if there was a way to print the full documentation of all commands. Something like my-app --help but that outputs all of the controllers and subcommands, including their descriptions and arguments, in a structured way.

If this feature does not exist, I am happy to try and make a pull request.

Cheers,
Cocco

@derks
Copy link
Member

derks commented Jan 19, 2023

@coccoinomane thank you for the kind words, and for your sponsorship (many thanks). I am happy Cement has been of value to you. I like the idea... kind of like a complete man page doc for all base and nested commands/options. This feature does not exist.

Some thoughts:

Without diving deep into the most complex piece of the framework (controllers), this can obviously be accomplished manually by creating a help command and templating out the entire output however you wish. This would give you full control over how the output is displayed, add context/usage/guides etc. But this would be yours, and not re-usable.... and would be more tedious to maintain (though, all docs are).

To make this a framework feature, I'd begin inspecting app.controller, which is always the base controller that handles runtime dispatch. With the base controller you'll find all of the loaded, and instantiated/setup controllers:

From within the base controller:

  • self._sub_parser_parents (dict)
  • self._sub_parsers (dict)
  • self._controllers (list)
  • self._controllers_map (dict)

The above assumes you are using ArgparseController (default). The _sub_parsers are the actual Argparse parsers. You could get creative by adding the following to all controllers:

  • Controller.Meta.title
  • Controller.Meta.usage
  • Controller.Meta.description
  • Controller.Meta.epilog

With the above, it would be relatively trivial to iterate over controllers/parsers/etc in a template and output the above attributes. I think the biggest challenge will be logical ordering so that it reads "top down", especially with regards to controller stacking.

Happy to help, and could try to set aside some time to dig in on this... I will say, out of all of Cement code base, controllers are a bit of deep dark black magic.

@coccoinomane
Copy link
Author

Thank you so much for your suggestions @derks , and sorry for the late reply.

I would like to make this a framework feature. I will try to implement it in the coming weeks, will keep you updated on my progress :-)

Thanks again, and have a nice weekend!
Cocco

@brianmezini
Copy link

brianmezini commented Aug 8, 2023

Did this feature ever get started? Seems pretty useful.

@coccoinomane
Copy link
Author

Hi @brianmezini!

I did not implement the feature on my side. I ended up copying and pasting from the -h commands 😅

Cheers,
Cocco

@brianmezini
Copy link

@coccoinomane Lol a man does what a man gotta do. I found the need for this feature at the end of my project, obviously to document the app so I ended up implementing a docs command that generates markdown formatted documentation from a Jinja2 template with the hints that @derks provided. Perhaps in the near future I could take a swing at implementing that in the cement cli.

@coccoinomane
Copy link
Author

@brianmezini Wow, that seems pretty cool 💪 Why don't you create a draft pull request with the code? No problem if it does not work. It can be a starting point for the community to implement the feature in Cement.

@brianmezini
Copy link

@coccoinomane that sounds like a solid next step. I have to polish my code a little bit first 😅

@coccoinomane
Copy link
Author

That sounds great, @brianmezini! Looking forward to help implementing it when the draft PR is out.

@brianmezini
Copy link

@coccoinomane @derks so the way I implemented the documentation in my app is by creating a docs command in the base controller. I can post the code and the Jinja2 template that I used but I don't know where or how it would fit in the framework. Also my code for the command is admittedly janky because it does the job for me so I didn't put much effort beyond that.

@brianmezini
Copy link

Command code: (pretty sure it can be done much better)

@ex(
    help="generate documentation in Markdown format",
    arguments=[
        (
            ["docs_file"],
            {
                "help": "File to generate documentation in. Defaults to DOCS.md.",
                "nargs": "?",
                "default": "DOCS.md"
            }
        )
    ]
)
def docs(self):
    docs_file = self.app.pargs.docs_file

    controllers: dict[str, Controller] = self._controllers_map
    cmds = {}

    for name, controller in controllers.items():
        if controller._meta.label == "base":
            continue
        
        if controller._meta.stacked_type == "nested":
            if controller._meta.stacked_on == "base":
                cmds[controller._meta.label] = {"help": controller._meta.help}

                for cmd in controller._collect_commands():
                    #print(cmd)
                    cmds[controller._meta.label][cmd["label"]] = {"help": cmd['parser_options']['help']}
                    cmds[controller._meta.label][cmd["label"]]["arguments"] = {"positional": [], "flags": []}
                    for arg in cmd["arguments"]:
                        # arg = (['-l', '--long-flag'], {'help': ""})
                        if arg[0][0].startswith("-"):

                            cmds[controller._meta.label][cmd["label"]]["arguments"]["flags"].append(
                                {
                                    "name": ' | '.join(arg[0]),
                                    "help": arg[1]['help']
                                }
                            )

                        else:
                            cmds[controller._meta.label][cmd["label"]]["arguments"]["positional"].append(
                                {
                                    "name": ' | '.join(arg[0]),
                                    "help": arg[1]['help']
                                }
                            )
            
            else:
                parent_controller = controllers[controller._meta.stacked_on]
                if parent_controller._meta.label in cmds.keys():
                    cmds[parent_controller._meta.label][controller._meta.label] = {"help": controller._meta.help}

                else:
                    cmds[parent_controller._meta.label] = {
                        "help": parent_controller._meta.help,
                        controller._meta.label: {
                            "help": controller._meta.help
                        }
                    }
                for cmd in controller._collect_commands():
                    #print(cmd)
                    cmds[parent_controller._meta.label][controller._meta.label][cmd["label"]] = {"help": cmd['parser_options']['help']}
                    cmds[parent_controller._meta.label][controller._meta.label][cmd["label"]]["arguments"] = {"positional": [], "flags": []}
                    for arg in cmd["arguments"]:
                        # arg = (['-l', '--long-flag'], {'help': ""})
                        if arg[0][0].startswith("-"):

                            cmds[parent_controller._meta.label][controller._meta.label][cmd["label"]]["arguments"]["flags"].append(
                                {
                                    "name": ' | '.join(arg[0]),
                                    "help": arg[1]['help']
                                }
                            )

                        else:
                            cmds[parent_controller._meta.label][controller._meta.label][cmd["label"]]["arguments"]["positional"].append(
                                {
                                    "name": ' | '.join(arg[0]),
                                    "help": arg[1]['help']
                                }
                            )

        elif controller._meta.stacked_type == "embedded":
            parent_controller = controllers[controller._meta.stacked_on]
            if controller._meta.stacked_on == "base":

                for cmd in controller._collect_commands():
                    #print(cmd)
                    cmds[cmd["label"]] = {"help": cmd['parser_options']['help']}
                    cmds[cmd["label"]]["arguments"] = {"positional": [], "flags": []}
                    for arg in cmd["arguments"]:
                        # arg = (['-l', '--long-flag'], {'help': ""})
                        if arg[0][0].startswith("-"):

                            cmds[cmd["label"]]["arguments"]["flags"].append(
                                {
                                    "name": ' | '.join(arg[0]),
                                    "help": arg[1]['help']
                                }
                            )

                        else:
                            cmds[cmd["label"]]["arguments"]["positional"].append(
                                {
                                    "name": ' | '.join(arg[0]),
                                    "help": arg[1]['help']
                                }
                            )
            
            elif parent_controller._meta.stacked_on == "base":

                for cmd in controller._collect_commands():
                    #print(cmd)
                    cmds[cmd["label"]] = {"help": cmd['parser_options']['help']}
                    cmds[cmd["label"]]["arguments"] = {"positional": [], "flags": []}
                    for arg in cmd["arguments"]:
                        # arg = (['-l', '--long-flag'], {'help': ""})
                        if arg[0][0].startswith("-"):

                            cmds[cmd["label"]]["arguments"]["flags"].append(
                                {
                                    "name": ' | '.join(arg[0]),
                                    "help": arg[1]['help']
                                }
                            )

                        else:
                            cmds[cmd["label"]]["arguments"]["positional"].append(
                                {
                                    "name": ' | '.join(arg[0]),
                                    "help": arg[1]['help']
                                }
                            )

    with open(docs_file, "w") as f:
        self.app.render({"cmds": cmds}, "docs.j2", f)

    self.app.log.info(f"Generated application documentation at {docs_file}.")

Jinja2 template:

{%- macro parse_commands(cmd_string, cmds, level) -%}
{% for cmd_name, cmd_conf in cmds.items() %}
{% if cmd_name != 'help' %}
{%- set cmd_string = cmd_string + " " + cmd_name %}
{%- set positional_args = [] %}
{%- set flag_args = [] %}
{{ '#' * level }} {{ cmd_name }}
{%- if 'arguments' in cmd_conf.keys() and cmd_conf['arguments'] != {} %}
{%- set args = cmd_conf['arguments'] %}
{%- if 'flags' in args.keys() and args['flags'] != [] %}
{%- set flag_args = args['flags'] %}
{%- set cmd_string = cmd_string + " [flags]" %}
{%- endif %}
{%- if 'positional' in args.keys() and args["positional"] != [] %}
{%- set positional_args = args['positional']  %}
{%- set cmd_string = cmd_string + " [arguments]" %}
{%- endif %}
**Usage:** `{{ cmd_string }}`

**Description:** {{ cmd_conf["help"] }}
{% if positional_args != [] %}
**Arguments:**
{%- for positional_arg in positional_args %}
- `{{ positional_arg['name'] }}` - {{ positional_arg["help"] }}
{%- endfor %}
{% endif %}
{%- if flag_args != [] %}
**Flags:**
{%- for flag_arg in flag_args %}
- `{{ flag_arg['name'] }}`  -  {{ flag_arg["help"] }}
{%- endfor %}
{% endif %}
{%- else %}
{%- set subcmd_string = cmd_string + " [subcommand]"  %}
**Usage:** `{{ subcmd_string }}`

where `[subcommand]` is one of:

{%- for subcmd_name, subcmd_conf in cmds[cmd_name].items() %}
{%- if subcmd_name != 'help' %}
- [`{{ subcmd_name }}`](#{{subcmd_name}})
{%- endif %}
{%- endfor %}

**Description:** {{ cmd_conf["help"] }}
{{- parse_commands(cmd_string, cmds[cmd_name], level+1) -}}
{%- endif %}
{%- endif %}
{%- endfor %}
{%- endmacro -%}
{%- set cmd_string = "<CLI_APP_NAME_HERE>" -%}
# Top level commands

**Usage:** `{{ cmd_string }} [command]`

where `[command]` is one of:
{%- for cmd_name, cmd_conf in cmds.items() %}
- [`{{ cmd_name }}`](#{{cmd_name}})
{%- endfor %}

{{ parse_commands(cmd_string, cmds, 2) }}

@coccoinomane
Copy link
Author

That's great @brianmezini, thank you for sharing! This is a great start.

I tried the code in my base controller. It worked fine! I just added some logging and fixed a small bug when an argument had no help field. Please find the revised code below.

I could not make the jinja template work, will work on it. Will also do a PR as soon as I have some free time :-)

Cheers,
Cocco

Revised code

@ex(
    help="Generate documentation in Markdown format",
    arguments=[
        (
            ["docs_file"],
            {
                "help": "File to generate documentation in. Defaults to DOCS.md.",
                "nargs": "?",
                "default": "DOCS.md",
            },
        )
    ],
)
def docs(self) -> None:
    docs_file = self.app.pargs.docs_file

    controllers: dict[str, Controller] = self._controllers_map
    cmds = {}

    for name, controller in controllers.items():
        if controller._meta.label == "base":
            continue

        self.app.log.info(f"Processing controller {controller._meta.label}")

        if controller._meta.stacked_type == "nested":
            if controller._meta.stacked_on == "base":
                cmds[controller._meta.label] = {"help": controller._meta.help}

                for cmd in controller._collect_commands():
                    self.app.log.info(f" - command {cmd['label']}")
                    if cmd["hide"] == True:
                        self.app.log.info(f"  \ hidden command - skipping")
                    cmds[controller._meta.label][cmd["label"]] = {
                        "help": cmd["parser_options"]["help"]
                    }
                    cmds[controller._meta.label][cmd["label"]]["arguments"] = {
                        "positional": [],
                        "flags": [],
                    }
                    for arg in cmd["arguments"]:
                        # arg = (['-l', '--long-flag'], {'help': ""})
                        help_string = arg[1].get("help", "")
                        if arg[0][0].startswith("-"):
                            cmds[controller._meta.label][cmd["label"]]["arguments"][
                                "flags"
                            ].append(
                                {"name": " | ".join(arg[0]), "help": help_string}
                            )

                        else:
                            cmds[controller._meta.label][cmd["label"]]["arguments"][
                                "positional"
                            ].append(
                                {"name": " | ".join(arg[0]), "help": help_string}
                            )

            else:
                parent_controller = controllers[controller._meta.stacked_on]
                if parent_controller._meta.label in cmds.keys():
                    cmds[parent_controller._meta.label][controller._meta.label] = {
                        "help": controller._meta.help
                    }

                else:
                    cmds[parent_controller._meta.label] = {
                        "help": parent_controller._meta.help,
                        controller._meta.label: {"help": controller._meta.help},
                    }
                for cmd in controller._collect_commands():
                    # print(cmd)
                    cmds[parent_controller._meta.label][controller._meta.label][
                        cmd["label"]
                    ] = {"help": cmd["parser_options"]["help"]}
                    cmds[parent_controller._meta.label][controller._meta.label][
                        cmd["label"]
                    ]["arguments"] = {"positional": [], "flags": []}
                    for arg in cmd["arguments"]:
                        help_string = arg[1].get("help", "")
                        # arg = (['-l', '--long-flag'], {'help': ""})
                        if arg[0][0].startswith("-"):
                            cmds[parent_controller._meta.label][
                                controller._meta.label
                            ][cmd["label"]]["arguments"]["flags"].append(
                                {"name": " | ".join(arg[0]), "help": help_string}
                            )

                        else:
                            cmds[parent_controller._meta.label][
                                controller._meta.label
                            ][cmd["label"]]["arguments"]["positional"].append(
                                {"name": " | ".join(arg[0]), "help": help_string}
                            )

        elif controller._meta.stacked_type == "embedded":
            parent_controller = controllers[controller._meta.stacked_on]
            if controller._meta.stacked_on == "base":
                for cmd in controller._collect_commands():
                    # print(cmd)
                    cmds[cmd["label"]] = {"help": cmd["parser_options"]["help"]}
                    cmds[cmd["label"]]["arguments"] = {
                        "positional": [],
                        "flags": [],
                    }
                    for arg in cmd["arguments"]:
                        help_string = arg[1].get("help", "")
                        # arg = (['-l', '--long-flag'], {'help': ""})
                        if arg[0][0].startswith("-"):
                            cmds[cmd["label"]]["arguments"]["flags"].append(
                                {"name": " | ".join(arg[0]), "help": help_string}
                            )

                        else:
                            cmds[cmd["label"]]["arguments"]["positional"].append(
                                {"name": " | ".join(arg[0]), "help": help_string}
                            )

            elif parent_controller._meta.stacked_on == "base":
                for cmd in controller._collect_commands():
                    # print(cmd)
                    cmds[cmd["label"]] = {"help": cmd["parser_options"]["help"]}
                    cmds[cmd["label"]]["arguments"] = {
                        "positional": [],
                        "flags": [],
                    }
                    for arg in cmd["arguments"]:
                        help_string = arg[1].get("help", "")
                        # arg = (['-l', '--long-flag'], {'help': ""})
                        if arg[0][0].startswith("-"):
                            cmds[cmd["label"]]["arguments"]["flags"].append(
                                {"name": " | ".join(arg[0]), "help": help_string}
                            )

                        else:
                            cmds[cmd["label"]]["arguments"]["positional"].append(
                                {"name": " | ".join(arg[0]), "help": help_string}
                            )

    with open(docs_file, "w") as f:
        self.app.render(data={"cmds": cmds}, template="docs.j2", out=f)

    self.app.log.info(f"Generated application documentation at {docs_file}.")

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

No branches or pull requests

3 participants