diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index b6c60ffd9..51674ccbb 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -79,13 +79,16 @@ 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, ) 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..c6b4ac939 --- /dev/null +++ b/pkgs/clan-cli/docs.py @@ -0,0 +1,213 @@ +import argparse +from dataclasses import dataclass + +from clan_cli import create_parser + + +@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) -> 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: + 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)