From 964625630e7594cc8e1cfd92ee760e515fd36a48 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 30 Apr 2024 18:53:00 +0200 Subject: [PATCH 1/2] add cli docs generator --- pkgs/clan-cli/clan_cli/__init__.py | 6 +- pkgs/clan-cli/clan_cli/machines/update.py | 3 +- pkgs/clan-cli/docs.py | 220 ++++++++++++++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 pkgs/clan-cli/docs.py diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index b6c60ffd9..49f0ed0cc 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -79,13 +79,17 @@ def create_parser(prog: str | None = None) -> argparse.ArgumentParser: "--flake", help="path to the flake where the clan resides in, can be a remote flake or local, can be set through the [CLAN_DIR] environment variable", default=get_clan_flake_toplevel_or_env(), + metavar="PATH", type=flake_path, + epilog="Default is dynamically determined based on the current directory.", ) subparsers = parser.add_subparsers() parser_backups = subparsers.add_parser( - "backups", help="manage backups of clan machines" + "backups", + help="manage backups of clan machines", + description="manage backups of clan machines", ) backups.register_parser(parser_backups) diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index 7f9fdcb5e..34c31a253 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -182,9 +182,10 @@ def register_update_parser(parser: argparse.ArgumentParser) -> None: parser.add_argument( "machines", type=str, - help="machine to update. if empty, update all machines", nargs="*", default=[], + metavar="MACHINE", + help="machine to update. If no machine is specified, all machines will be updated.", ) parser.add_argument( "--target-host", diff --git a/pkgs/clan-cli/docs.py b/pkgs/clan-cli/docs.py new file mode 100644 index 000000000..44870b06b --- /dev/null +++ b/pkgs/clan-cli/docs.py @@ -0,0 +1,220 @@ +import argparse +from dataclasses import dataclass +from typing import Tuple +from clan_cli import create_parser +import argparse +import os +from dataclasses import dataclass +from typing import Tuple + + +@dataclass +class Option: + name: str + description: str + default: str | None = None + metavar: str | None = None + epilog: str | None = None + + +@dataclass +class Subcommand: + name: str + description: str | None = None + epilog: str | None = None + + +@dataclass +class Category: + title: str + # Flags such as --example, -e + options: list[Option] + # Positionals such as 'cmd ' + positionals: list[Option] + + # Subcommands such as clan 'machines' + # In contrast to an option it is a command that can have further children + subcommands: list[Subcommand] + # Description of the command + description: str | None = None + # Additional information, typically displayed at the bottom + epilog: str | None = None + # What level of depth the category is at (i.e. 'backups list' is 2, 'backups' is 1, 'clan' is 0) + level: int = 0 + + +def indent_next(text: str, indent_size: int = 4) -> str: + """ + Indent all lines in a string except the first line. + This is useful for adding multiline texts a lists in Markdown. + """ + indent = " " * indent_size + lines = text.split("\n") + indented_text = lines[0] + ("\n" + indent).join(lines[1:]) + return indented_text + + +def indent_all(text: str, indent_size: int = 4) -> str: + """ + Indent all lines in a string. + """ + indent = " " * indent_size + lines = text.split("\n") + indented_text = indent + ("\n" + indent).join(lines) + return indented_text + + +def get_subcommands( + parser: argparse.ArgumentParser, + to: list[Category], + level: int = 0, + prefix: list[str] = [], +) -> Tuple[list[Option], list[Option], list[Subcommand]]: + """ + Generate Markdown documentation for an argparse.ArgumentParser instance including its subcommands. + + :param parser: The argparse.ArgumentParser instance. + :param level: Current depth of subcommand. + :return: Markdown formatted documentation as a string. + """ + + # Document each argument + # --flake --option --debug, etc. + flag_options: list[Option] = [] + positional_options: list[Option] = [] + subcommands: list[Subcommand] = [] + + for action in parser._actions: + + if isinstance(action, argparse._HelpAction): + # Pseudoaction that holds the help message + continue + + if isinstance(action, argparse._SubParsersAction): + continue # Subparsers handled sperately + + option_strings = ", ".join(action.option_strings) + if option_strings: + flag_options.append( + Option( + name=option_strings, + description=action.help if action.help else "", + default=action.default if action.default is not None else "", + metavar=f"{action.metavar}" if action.metavar else "", + ) + ) + + if not option_strings: + # Positional arguments + positional_options.append( + Option( + name=action.dest, + description=action.help if action.help else "", + default=action.default if action.default is not None else "", + metavar=f"{action.metavar}" if action.metavar else "", + ) + ) + + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + + subparsers: dict[str, argparse.ArgumentParser] = action.choices + + for name, subparser in subparsers.items(): + parent = " ".join(prefix) + + sub_command = Subcommand(name=name, description=subparser.description) + subcommands.append(sub_command) + + (_options, _positionals, _subcommands) = get_subcommands( + parser=subparser, to=to, level=level + 1, prefix=[*prefix, name] + ) + + to.append( + Category( + title=f"{parent} {name}", + description=subparser.description, + epilog=subparser.epilog, + level=level, + options=_options, + positionals=_positionals, + subcommands=_subcommands, + ) + ) + + return (flag_options, positional_options, subcommands) + + +def collect_commands() -> list[Category]: + """ + Returns a sorted list of all available commands. + + i.e. + a... + backups + backups create + backups list + backups restore + c... + + Commands are sorted alphabetically and kept in groups. + + """ + parser = create_parser() + + result: list[Category] = [] + + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + subparsers: dict[str, argparse.ArgumentParser] = action.choices + for name, subparser in subparsers.items(): + + (_options, _positionals, _subcommands) = get_subcommands( + subparser, to=result, level=2, prefix=[name] + ) + result.append( + Category( + title=name, + description=subparser.description, + options=_options, + positionals=_positionals, + subcommands=_subcommands, + level=1, + ) + ) + + def weight_cmd_groups(c: Category): + sub = [o for o in result if o.title.startswith(c.title) and o.title != c.title] + weight = len(c.title.split(" ")) + if sub: + weight = len(sub[0].title.split(" ")) + + # 1. Sort by toplevel name alphabetically + # 2. sort by custom weight to keep groups together + # 3. sort by title alphabetically + return (c.title.split(" ")[0], weight, c.title) + + result = sorted(result, key=weight_cmd_groups) + + # for c in result: + # print(c.title) + + return result + + +if __name__ == "__main__": + cmds = collect_commands() + + # TODO: proper markdown + markdown = "" + for cmd in cmds: + markdown += f"## {cmd.title}\n\n" + markdown += f"{cmd.description}\n" if cmd.description else "" + markdown += f"{cmd.options}\n" if cmd.description else "" + markdown += f"{cmd.subcommands}\n" if cmd.description else "" + markdown += f"{cmd.positionals}\n" if cmd.description else "" + markdown += f"{cmd.epilog}\n" if cmd.description else "" + + break + + print(markdown) From ca6cfca589397928a10d54ccd1ad67f2e53c3873 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 30 Apr 2024 18:54:11 +0200 Subject: [PATCH 2/2] add cli docs generator --- pkgs/clan-cli/clan_cli/__init__.py | 1 - pkgs/clan-cli/docs.py | 13 +++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index 49f0ed0cc..51674ccbb 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -81,7 +81,6 @@ def create_parser(prog: str | None = None) -> argparse.ArgumentParser: default=get_clan_flake_toplevel_or_env(), metavar="PATH", type=flake_path, - epilog="Default is dynamically determined based on the current directory.", ) subparsers = parser.add_subparsers() diff --git a/pkgs/clan-cli/docs.py b/pkgs/clan-cli/docs.py index 44870b06b..c6b4ac939 100644 --- a/pkgs/clan-cli/docs.py +++ b/pkgs/clan-cli/docs.py @@ -1,11 +1,7 @@ import argparse from dataclasses import dataclass -from typing import Tuple + from clan_cli import create_parser -import argparse -import os -from dataclasses import dataclass -from typing import Tuple @dataclass @@ -69,7 +65,7 @@ def get_subcommands( to: list[Category], level: int = 0, prefix: list[str] = [], -) -> Tuple[list[Option], list[Option], list[Subcommand]]: +) -> tuple[list[Option], list[Option], list[Subcommand]]: """ Generate Markdown documentation for an argparse.ArgumentParser instance including its subcommands. @@ -85,7 +81,6 @@ def get_subcommands( subcommands: list[Subcommand] = [] for action in parser._actions: - if isinstance(action, argparse._HelpAction): # Pseudoaction that holds the help message continue @@ -117,7 +112,6 @@ def get_subcommands( for action in parser._actions: if isinstance(action, argparse._SubParsersAction): - subparsers: dict[str, argparse.ArgumentParser] = action.choices for name, subparser in subparsers.items(): @@ -168,7 +162,6 @@ def collect_commands() -> list[Category]: if isinstance(action, argparse._SubParsersAction): subparsers: dict[str, argparse.ArgumentParser] = action.choices for name, subparser in subparsers.items(): - (_options, _positionals, _subcommands) = get_subcommands( subparser, to=result, level=2, prefix=[name] ) @@ -183,7 +176,7 @@ def collect_commands() -> list[Category]: ) ) - def weight_cmd_groups(c: Category): + def weight_cmd_groups(c: Category) -> tuple[str, int, str]: sub = [o for o in result if o.title.startswith(c.title) and o.title != c.title] weight = len(c.title.split(" ")) if sub: