add cli docs generator

This commit is contained in:
Johannes Kirschbauer 2024-04-30 18:53:00 +02:00
parent 480bb396ed
commit 1051367ca4
Signed by: hsjobeki
SSH Key Fingerprint: SHA256:vX3utDqig7Ph5L0JPv87ZTPb/w7cMzREKVZzzLFg9qU
3 changed files with 227 additions and 2 deletions

View File

@ -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)

View File

@ -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",

220
pkgs/clan-cli/docs.py Normal file
View File

@ -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 <example>'
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)