forked from clan/clan-core
344 lines
11 KiB
Python
344 lines
11 KiB
Python
import argparse
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
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
|
|
|
|
def to_md_li(self, delim: str = "-") -> str:
|
|
# - **--example, `--e`**: <PATH> {description} (Default: `default` {epilog})
|
|
md_li = f"{delim} **{self.name}**: "
|
|
md_li += f"`<{self.metavar}>` " if self.metavar else ""
|
|
md_li += f"(Default: `{self.default}`) " if self.default else ""
|
|
md_li += indent_next(
|
|
f"\n{self.description.strip()}" if self.description else ""
|
|
)
|
|
md_li += indent_next(f"\n{self.epilog.strip()}" if self.epilog else "")
|
|
|
|
return md_li
|
|
|
|
|
|
@dataclass
|
|
class Subcommand:
|
|
name: str
|
|
description: str | None = None
|
|
epilog: str | None = None
|
|
|
|
def to_md_li(self, parent: "Category") -> str:
|
|
md_li = f"""- **[{self.name}](#{"-".join(parent.title.split(" "))}-{self.name})**: """
|
|
md_li += indent_next(f"{self.description.strip()} " if self.description else "")
|
|
md_li += indent_next(f"\n{self.epilog.strip()}" if self.epilog else "")
|
|
|
|
return md_li
|
|
|
|
|
|
icon_table = {
|
|
"backups": ":material-backup-restore: ",
|
|
"config": ":material-shape-outline: ",
|
|
"facts": ":simple-databricks: ",
|
|
"flakes": ":material-snowflake: ",
|
|
"flash": ":material-flash: ",
|
|
"history": ":octicons-history-24: ",
|
|
"machines": ":octicons-devices-24: ",
|
|
"secrets": ":octicons-passkey-fill-24: ",
|
|
"ssh": ":material-ssh: ",
|
|
"vms": ":simple-virtualbox: ",
|
|
}
|
|
|
|
|
|
@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 to_md_li(self, level: int = 1) -> str:
|
|
md_li = ""
|
|
if level == self.level:
|
|
icon = icon_table.get(self.title, "")
|
|
md_li += f"""- **[{icon}{self.title}](./{"-".join(self.title.split(" "))}.md)**\n\n"""
|
|
md_li += f"""{indent_all("---", 4)}\n\n"""
|
|
md_li += indent_all(
|
|
f"{self.description.strip()}\n" if self.description else "", 4
|
|
)
|
|
md_li += "\n"
|
|
md_li += indent_all(f"{self.epilog.strip()}\n" if self.epilog else "", 4)
|
|
md_li += "\n"
|
|
|
|
return md_li
|
|
|
|
|
|
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, str, int]:
|
|
sub = [o for o in result if o.title.startswith(c.title) and o.title != c.title]
|
|
weight = 10 - len(c.title.split(" "))
|
|
if sub:
|
|
weight = 10 - 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], c.title, weight)
|
|
|
|
result = sorted(result, key=weight_cmd_groups)
|
|
|
|
return result
|
|
|
|
|
|
def build_command_reference() -> None:
|
|
"""
|
|
Function that will build the reference
|
|
and write it to the out path.
|
|
"""
|
|
cmds = collect_commands()
|
|
|
|
folder = Path("out")
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Index file
|
|
markdown = "# CLI Overview\n\n"
|
|
categories_fmt = ""
|
|
for cat in cmds:
|
|
categories_fmt += f"{cat.to_md_li()}\n\n" if cat.to_md_li() else ""
|
|
|
|
if categories_fmt:
|
|
markdown += """## Overview\n\n"""
|
|
markdown += '<div class="grid cards" markdown>\n\n'
|
|
markdown += categories_fmt
|
|
markdown += "</div>"
|
|
markdown += "\n"
|
|
|
|
with open(folder / "index.md", "w") as f:
|
|
f.write(markdown)
|
|
|
|
# Each top level category is a separate file
|
|
files: dict[Path, str] = {}
|
|
|
|
for t in [cmd.title for cmd in cmds]:
|
|
print(t)
|
|
|
|
for cmd in cmds:
|
|
# Collect all commands starting with the same name into one file
|
|
filename = cmd.title.split(" ")[0]
|
|
markdown = files.get(folder / f"{filename}.md", "")
|
|
|
|
markdown += f"{'#'*(cmd.level)} {cmd.title.capitalize()}\n\n"
|
|
markdown += f"{cmd.description}\n\n" if cmd.description else ""
|
|
|
|
# usage: clan vms run [-h] machine
|
|
markdown += f"""Usage: `clan {cmd.title}`\n\n"""
|
|
|
|
# options:
|
|
# -h, --help show this help message and exit
|
|
|
|
# Positional arguments
|
|
positionals_fmt = ""
|
|
for option in cmd.positionals:
|
|
positionals_fmt += f"""{option.to_md_li("1.")}\n"""
|
|
|
|
if len(cmd.positionals):
|
|
markdown += """!!! info "Positional arguments"\n"""
|
|
markdown += indent_all(positionals_fmt)
|
|
markdown += "\n"
|
|
|
|
options_fmt = ""
|
|
for option in cmd.options:
|
|
options_fmt += f"{option.to_md_li()}\n"
|
|
|
|
# options:
|
|
if len(cmd.options):
|
|
markdown += """??? info "Options"\n"""
|
|
markdown += indent_all(options_fmt)
|
|
markdown += "\n"
|
|
|
|
def asort(s: Subcommand) -> str:
|
|
return s.name
|
|
|
|
commands_fmt = ""
|
|
for sub_cmd in sorted(cmd.subcommands, key=asort):
|
|
commands_fmt += f"{sub_cmd.to_md_li(cmd)}\n"
|
|
|
|
if commands_fmt:
|
|
markdown += """!!! info "Commands"\n"""
|
|
markdown += indent_all(commands_fmt)
|
|
markdown += "\n"
|
|
|
|
files[folder / f"{filename}.md"] = markdown
|
|
|
|
for fname, content in files.items():
|
|
with open(fname, "w") as f:
|
|
f.write(content)
|
|
|
|
|
|
def main() -> None:
|
|
if len(sys.argv) != 2:
|
|
print("Usage: python docs.py <command>")
|
|
print("Available commands: reference")
|
|
sys.exit(1)
|
|
|
|
command = sys.argv[1]
|
|
|
|
if command == "reference":
|
|
build_command_reference()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|