Skip to content
This repository has been archived by the owner on Apr 6, 2024. It is now read-only.

Create a version to use with typer>=0.4.0 and click 8. #67

Closed
wants to merge 9 commits into from
Closed

Create a version to use with typer>=0.4.0 and click 8. #67

wants to merge 9 commits into from

Conversation

cdcadman
Copy link
Contributor

@cdcadman cdcadman commented Apr 27, 2022

This pull request is intended to resolve #50 and #53. I tested it on Windows Powershell with click 8.0.0 and click 7.1.2, and also ran the tests under both versions of click.

@cdcadman cdcadman marked this pull request as ready for review April 27, 2022 23:05
@cdcadman cdcadman changed the title Create a version to use with typer ^0.4.0. Create a version to use with typer>=0.4.0 and click 8. Apr 29, 2022
@cdcadman
Copy link
Contributor Author

@tiangolo , I find this package very helpful to complement typer, and I hope this PR will help keep it up-to-date. The hard work was already done in the typer package. The key to getting autocompletion to work in typer-cli was to make TyperCLIGroup inherit from typer.core.TyperGroup (line 54). I'm not sure if there was a better approach than to set no_args_is_help=True in the callback for the typer CLI app (line 159). The no_args_is_help parameter default changed from None to False in typer 0.4.0, and this change caused test_script_help in test_help.py and test_sub.py to fail. My line 159 allows them to pass.

The test workflow passes in my repo, and I updated it to check click 7 and python 3.9. I'm not sure if some other workflows need updating or if I should have left the version number in init.py alone.

@idvorkin
Copy link

Hooray, very excited for this to merge!

@jimmywan
Copy link

jimmywan commented Aug 2, 2022

BUMP Any updates on this?

@cdcadman
Copy link
Contributor Author

cdcadman commented Aug 4, 2022

@jimmywan , I noticed that @tiangolo has done some recent work in the typer repo, but I haven't heard from any typer-cli maintainers regarding this pull request. I also just noticed that the test workflow here fails on the latest pre-release version of poetry, while typer uses flint instead of poetry. I wouldn't mind converting typer-cli to use flint if that makes sense.

@jimmywan
Copy link

jimmywan commented Aug 9, 2022

@jimmywan , I noticed that @tiangolo has done some recent work in the typer repo, but I haven't heard from any typer-cli maintainers regarding this pull request. I also just noticed that the test workflow here fails on the latest pre-release version of poetry, while typer uses flint instead of poetry. I wouldn't mind converting typer-cli to use flint if that makes sense.

@cdcadman Thx for the context. I'm actually rolling off some legacy maintenance work that I had done that references this so if there is no immediate fix, I'll probably just bow out here.

@drkeoni
Copy link

drkeoni commented Oct 7, 2022

I'm interested in this pull request too. FWIW I checked out @cdcadman 's fork and ran the tests under Mac OSX 12.6, python 3.9.12. Everything passed.

@drkeoni
Copy link

drkeoni commented Oct 7, 2022

For anyone who got to here because they really want to use typer-cli to generate usage documentation...but it's not installing well into their current environments.

I added a docs generate command to my app with the following code copied (and slightly modified) from this repo:

from pathlib import Path
from typing import cast

import typer
import typer.core
from click import Command, Group


app = typer.Typer()


def get_docs_for_click(
    *,
    obj: Command,
    ctx: typer.Context,
    indent: int = 0,
    name: str = "",
    call_prefix: str = "",
) -> str:
    docs = "#" * (1 + indent)
    command_name = name or obj.name
    if call_prefix:
        command_name = f"{call_prefix} {command_name}"
    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: Group = cast(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()
                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


@app.command(help="Generate markdown version of usage documentation")
def generate(
    ctx: typer.Context,
    name: str = typer.Option("", help="The name of the CLI program to use in docs."),
    output: Path = typer.Option(
        None,
        help="An output file to write docs to, like README.md.",
        file_okay=True,
        dir_okay=False,
    ),
) -> None:
    """
    Generate Markdown docs for a Typer app.
    """
    from ..main import app as main_app
    click_obj = typer.main.get_command(main_app)
    docs = get_docs_for_click(obj=click_obj, ctx=ctx, name=name)
    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)

I saved this to a file called docs.py. My module layout is

package/
    _init__.py
    main.py
    commands/
       docs.py
       etc...

Within main.py I added the docs generate command with:

from .commands.docs import app as docs_app
app.add_typer(docs_app, name="docs", help="Generate documentation")

alexreg added a commit to alexreg/typer-cloup-cli that referenced this pull request Oct 20, 2022
alexreg added a commit to alexreg/typer-cloup-cli that referenced this pull request Oct 20, 2022
alexreg added a commit to alexreg/typer-cloup-cli that referenced this pull request Oct 20, 2022
alexreg added a commit to alexreg/typer-cloup-cli that referenced this pull request Oct 22, 2022
@barsilksec
Copy link

Hey! want to see if there's any updates on when this will be merged?
Looking forward to using it :)

@cdcadman
Copy link
Contributor Author

I think I will close this PR soon. The typer repo is being maintained, but typer-cli isn't getting attention from anyone with write access. The code that drkeoni pasted above helped solve the problem which prompted me to create this PR.

@cdcadman
Copy link
Contributor Author

I am closing, since this has been open for a long time. Here is the main.py file that I was trying to merge in:

import importlib.util
import re
import sys
from pathlib import Path
from typing import Any, List, Optional, cast

import click
import typer
import typer.core
from click import Command, Group, Option

from . import __version__

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

app = typer.Typer()
utils_app = typer.Typer(help="Extra utility commands for Typer apps.")
app.add_typer(utils_app, name="utils")


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)  # type: ignore
    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)


def print_version(ctx: click.Context, param: Option, value: bool) -> None:
    if not value or ctx.resilient_parsing:
        return
    typer.echo(f"Typer CLI version: {__version__}")
    raise typer.Exit()


@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."),
    version: bool = typer.Option(
        False, "--version", help="Print version and exit.", callback=print_version  # type: ignore
    ),
) -> None:
    """
    Typer CLI.
    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 = "",
) -> str:
    docs = "#" * (1 + indent)
    command_name = name or obj.name
    if call_prefix:
        command_name = f"{call_prefix} {command_name}"
    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 += f"**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 += f"**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: Group = cast(Group, obj)
        commands = group.list_commands(ctx)
        if commands:
            docs += f"**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()
                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: Path = typer.Option(
        None,
        help="An output file to write docs to, like README.md.",
        file_okay=True,
        dir_okay=False,
    ),
) -> None:
    """
    Generate Markdown docs for a Typer app.
    """
    typer_obj = get_typer_from_state()
    if not typer_obj:
        typer.echo(f"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)
    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)


def main() -> Any:
    return app()

@cdcadman cdcadman closed this Jan 30, 2023
@timothyjlaurent
Copy link

😭

@tiangolo
Copy link
Owner

Thank you @cdcadman! 🍰

This was included as part of #82, it even preserved your commits. 😎

And thanks everyone for the input here. ☕

I'll make a couple of extra tweaks and release version 0.0.13 in the next hours. 🚀

Sorry for the long delay! 🙈 I wanted to personally address each issue/PR and they piled up through time, but now I'm checking each one in order.

@omBratteng
Copy link
Contributor

This was included as part of #82, it even preserved your commits. 😎

I thought that was the right thing to do, as @cdcadman did all of the work!

@cdcadman
Copy link
Contributor Author

Thanks @omBratteng for keeping my commits alive. Thanks @tiangolo for merging #82 and releasing typer-cli version 0.0.13.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Typer-cli (0.0.12) depends on typer (>=0.3.0,<0.4.0), so typer 0.4.0 AND typer-cli 0.0.12 are incompatible
8 participants