Skip to content

Commit

Permalink
Problem command description being shortened
Browse files Browse the repository at this point in the history
In the generated doc the sub command description were being shortened,
for example:

    allocate: Notify a CRN to start an instance (for...
    confidential: Create (optional), start and unlock a...

Solution:
Vendroize and customize the tool to generate the doc

Switch the char limit from 45 to 1000
It will still only take the first paragraph
  • Loading branch information
olethanh committed Oct 23, 2024
1 parent 5487fe9 commit 4a0f2a8
Show file tree
Hide file tree
Showing 2 changed files with 281 additions and 1 deletion.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ If you have the `aleph-docs` repository cloned as a sibling folder to your curre
command to generate updated documentation:

```shell
typer src/aleph_client/__main__.py utils docs --name aleph --title 'Aleph CLI Documentation' --output ../aleph-docs/docs/tools/aleph-client/usage.md
./scripts/gendoc.py src/aleph_client/__main__.py docs --name aleph --title 'Aleph CLI Documentation' --output ../aleph-docs/docs/tools/aleph-client/usage.md
```

After generating the documentation, you may need to update the path for the private key, as this depends on the user
Expand Down
280 changes: 280 additions & 0 deletions scripts/gendoc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
Copied from typer.cli.py to customise doc generation
"""

import click
import importlib.util
import re
import sys
import typer
import typer.core
from click import Command, Group
from pathlib import Path
from typing import Any, List, Optional

default_app_names = ("app", "cli", "main")
default_func_names = ("main", "cli", "app")

utils_app = typer.Typer(help="Extra utility commands for Typer apps.")


class State:
def __init__(self) -> None:
self.app: Optional[str] = None
self.func: Optional[str] = None
self.file: Optional[Path] = None
self.module: Optional[str] = None


state = State()


def maybe_update_state(ctx: click.Context) -> None:
path_or_module = ctx.params.get("path_or_module")
if path_or_module:
file_path = Path(path_or_module)
if file_path.exists() and file_path.is_file():
state.file = file_path
else:
if not re.fullmatch(r"[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*", path_or_module):
typer.echo(f"Not a valid file or Python module: {path_or_module}", err=True)
sys.exit(1)
state.module = path_or_module
app_name = ctx.params.get("app")
if app_name:
state.app = app_name
func_name = ctx.params.get("func")
if func_name:
state.func = func_name


class TyperCLIGroup(typer.core.TyperGroup):
def list_commands(self, ctx: click.Context) -> List[str]:
self.maybe_add_run(ctx)
return super().list_commands(ctx)

def get_command(self, ctx: click.Context, name: str) -> Optional[Command]:
self.maybe_add_run(ctx)
return super().get_command(ctx, name)

def invoke(self, ctx: click.Context) -> Any:
self.maybe_add_run(ctx)
return super().invoke(ctx)

def maybe_add_run(self, ctx: click.Context) -> None:
maybe_update_state(ctx)
maybe_add_run_to_cli(self)


def get_typer_from_module(module: Any) -> Optional[typer.Typer]:
# Try to get defined app
if state.app:
obj = getattr(module, state.app, None)
if not isinstance(obj, typer.Typer):
typer.echo(f"Not a Typer object: --app {state.app}", err=True)
sys.exit(1)
return obj
# Try to get defined function
if state.func:
func_obj = getattr(module, state.func, None)
if not callable(func_obj):
typer.echo(f"Not a function: --func {state.func}", err=True)
sys.exit(1)
sub_app = typer.Typer()
sub_app.command()(func_obj)
return sub_app
# Iterate and get a default object to use as CLI
local_names = dir(module)
local_names_set = set(local_names)
# Try to get a default Typer app
for name in default_app_names:
if name in local_names_set:
obj = getattr(module, name, None)
if isinstance(obj, typer.Typer):
return obj
# Try to get any Typer app
for name in local_names_set - set(default_app_names):
obj = getattr(module, name)
if isinstance(obj, typer.Typer):
return obj
# Try to get a default function
for func_name in default_func_names:
func_obj = getattr(module, func_name, None)
if callable(func_obj):
sub_app = typer.Typer()
sub_app.command()(func_obj)
return sub_app
# Try to get any func app
for func_name in local_names_set - set(default_func_names):
func_obj = getattr(module, func_name)
if callable(func_obj):
sub_app = typer.Typer()
sub_app.command()(func_obj)
return sub_app
return None


def get_typer_from_state() -> Optional[typer.Typer]:
spec = None
if state.file:
module_name = state.file.name
spec = importlib.util.spec_from_file_location(module_name, str(state.file))
elif state.module:
spec = importlib.util.find_spec(state.module)
if spec is None:
if state.file:
typer.echo(f"Could not import as Python file: {state.file}", err=True)
else:
typer.echo(f"Could not import as Python module: {state.module}", err=True)
sys.exit(1)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore
obj = get_typer_from_module(module)
return obj


def maybe_add_run_to_cli(cli: click.Group) -> None:
if "run" not in cli.commands:
if state.file or state.module:
obj = get_typer_from_state()
if obj:
obj._add_completion = False
click_obj = typer.main.get_command(obj)
click_obj.name = "run"
if not click_obj.help:
click_obj.help = "Run the provided Typer app."
cli.add_command(click_obj)


@utils_app.callback(cls=TyperCLIGroup, no_args_is_help=True)
def callback(
ctx: typer.Context,
*,
path_or_module: str = typer.Argument(None),
app: str = typer.Option(None, help="The typer app object/variable to use."),
func: str = typer.Option(None, help="The function to convert to Typer."),
) -> None:
"""
Run Typer scripts with completion, without having to create a package.
You probably want to install completion for the typer command:
$ typer --install-completion
https://typer.tiangolo.com/
"""
maybe_update_state(ctx)


def get_docs_for_click(
*,
obj: Command,
ctx: typer.Context,
indent: int = 0,
name: str = "",
call_prefix: str = "",
title: Optional[str] = None,
) -> str:
docs = "#" * (1 + indent)
command_name = name or obj.name
if call_prefix:
command_name = f"{call_prefix} {command_name}"
if not title:
title = f"`{command_name}`" if command_name else "CLI"
docs += f" {title}\n\n"
if obj.help:
docs += f"{obj.help}\n\n"
usage_pieces = obj.collect_usage_pieces(ctx)
if usage_pieces:
docs += "**Usage**:\n\n"
docs += "```console\n"
docs += "$ "
if command_name:
docs += f"{command_name} "
docs += f"{' '.join(usage_pieces)}\n"
docs += "```\n\n"
args = []
opts = []
for param in obj.get_params(ctx):
rv = param.get_help_record(ctx)
if rv is not None:
if param.param_type_name == "argument":
args.append(rv)
elif param.param_type_name == "option":
opts.append(rv)
if args:
docs += "**Arguments**:\n\n"
for arg_name, arg_help in args:
docs += f"* `{arg_name}`"
if arg_help:
docs += f": {arg_help}"
docs += "\n"
docs += "\n"
if opts:
docs += "**Options**:\n\n"
for opt_name, opt_help in opts:
docs += f"* `{opt_name}`"
if opt_help:
docs += f": {opt_help}"
docs += "\n"
docs += "\n"
if obj.epilog:
docs += f"{obj.epilog}\n\n"
if isinstance(obj, Group):
group = obj
commands = group.list_commands(ctx)
if commands:
docs += "**Commands**:\n\n"
for command in commands:
command_obj = group.get_command(ctx, command)
assert command_obj
docs += f"* `{command_obj.name}`"
command_help = command_obj.get_short_help_str(limit=1000)
if command_help:
docs += f": {command_help}"
docs += "\n"
docs += "\n"
for command in commands:
command_obj = group.get_command(ctx, command)
assert command_obj
use_prefix = ""
if command_name:
use_prefix += f"{command_name}"
docs += get_docs_for_click(obj=command_obj, ctx=ctx, indent=indent + 1, call_prefix=use_prefix)
return docs


@utils_app.command()
def docs(
ctx: typer.Context,
name: str = typer.Option("", help="The name of the CLI program to use in docs."),
output: Optional[Path] = typer.Option(
None,
help="An output file to write docs to, like README.md.",
file_okay=True,
dir_okay=False,
),
title: Optional[str] = typer.Option(
None,
help="The title for the documentation page. If not provided, the name of " "the program is used.",
),
) -> None:
"""
Generate Markdown docs for a Typer app.
"""
typer_obj = get_typer_from_state()
if not typer_obj:
typer.echo("No Typer app found", err=True)
raise typer.Abort()
click_obj = typer.main.get_command(typer_obj)
docs = get_docs_for_click(obj=click_obj, ctx=ctx, name=name, title=title)
clean_docs = f"{docs.strip()}\n"
if output:
output.write_text(clean_docs)
typer.echo(f"Docs saved to: {output}")
else:
typer.echo(clean_docs)


utils_app()

0 comments on commit 4a0f2a8

Please sign in to comment.