Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 25 additions & 22 deletions great_docs/_api_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,10 +870,7 @@ def _import_cli_from_source(
import importlib
import sys

try:
import click
except ImportError:
return None
from ._typer_cli import to_click_command

str_dir = str(tmp_dir)
sys.path.insert(0, str_dir)
Expand All @@ -886,20 +883,20 @@ def _import_cli_from_source(

module = importlib.import_module(cli_module)

# Find Click command/group
# Find a Click command/group or Typer app
cli_obj = None
for attr_name in ["cli", "main", "app", "command"]:
obj = getattr(module, attr_name, None)
if isinstance(obj, (click.Command, click.Group)):
obj = to_click_command(getattr(module, attr_name, None))
if obj is not None:
cli_obj = obj
break

if cli_obj is None:
for attr_name in dir(module):
if attr_name.startswith("_"):
continue
obj = getattr(module, attr_name)
if isinstance(obj, (click.Command, click.Group)):
obj = to_click_command(getattr(module, attr_name))
if obj is not None:
cli_obj = obj
break

Expand Down Expand Up @@ -1331,37 +1328,43 @@ def snapshot_cli_from_click(cli_obj: object) -> CliCommandInfo | None:
Parameters
----------
cli_obj
A `click.BaseCommand` instance (Command or Group).
A `click.BaseCommand` instance (Command or Group), or a `typer.Typer`
app (which is converted to its underlying Click command).

Returns
-------
CliCommandInfo or None
The CLI snapshot, or `None` if `cli_obj` is not a Click command.
The CLI snapshot, or `None` if `cli_obj` is not a Click command or
Typer app.
"""
try:
import click
except ImportError: # pragma: no cover
return None # pragma: no cover
from ._typer_cli import to_click_command

if not isinstance(cli_obj, (click.Command, click.Group)):
cmd = to_click_command(cli_obj)
if cmd is None:
return None

return _snapshot_click_command(cli_obj)
return _snapshot_click_command(cmd)


def _snapshot_click_command(cmd: object) -> CliCommandInfo:
"""Recursively snapshot a Click command tree."""
import click
"""Recursively snapshot a Click command tree.

Parameters are classified with duck-typing (`param_type_name` / a dict-like `commands` mapping)
rather than `isinstance` so this handles both plain Click commands and Typer's vendored-Click
commands.
"""
from ._typer_cli import is_cli_group, param_kind

name = getattr(cmd, "name", "") or ""
help_text = getattr(cmd, "help", "") or ""
hidden = getattr(cmd, "hidden", False)
deprecated = getattr(cmd, "deprecated", False)
is_group = isinstance(cmd, click.Group)
is_group = is_cli_group(cmd)

options: list[CliOptionInfo] = []
for param in getattr(cmd, "params", []):
if isinstance(param, click.Option):
kind = param_kind(param)
if kind == "option":
# Use the longest option name (e.g. --verbose over -v)
opt_name = max(param.opts, key=len) if param.opts else param.name or ""
options.append(
Expand All @@ -1374,7 +1377,7 @@ def _snapshot_click_command(cmd: object) -> CliCommandInfo:
help=param.help,
)
)
elif isinstance(param, click.Argument):
elif kind == "argument":
options.append(
CliOptionInfo(
name=param.name or "",
Expand Down
102 changes: 102 additions & 0 deletions great_docs/_typer_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Typer CLI support for the Python (Click) CLI reference generator.

Typer is a Python CLI framework that is built on top of Click: a `typer.Typer` app is not itself a
`click.Command`/`click.Group`, but it can be turned into one with `typer.main.get_command`. Once
converted, the resulting object is an ordinary Click command tree, so all of the existing Click
introspection in `core.py` (options, arguments, subcommands, `--help` text) and `_api_diff.py` (CLI
snapshots) works unchanged.

These helpers are the single place that knows about Typer. Discovery code should detect candidates
with `is_cli_command()` and normalize them to a Click object with `to_click_command()` before
introspecting. Both degrade gracefully when Typer (or even Click) is not installed.
"""

from __future__ import annotations

from typing import Any


def is_typer_app(obj: Any) -> bool:
"""Return `True` when *obj* is a `typer.Typer` application instance.

Returns `False` (rather than raising) when Typer is not installed.
"""
try:
import typer
except ImportError:
return False
return isinstance(obj, typer.Typer)


def to_click_command(obj: Any) -> Any | None:
"""Coerce *obj* into a Click command, converting Typer apps.

Parameters
----------
obj
A candidate CLI object. Already-Click `Command`/`Group` instances are returned unchanged.
`typer.Typer` apps are converted to their underlying Click command via
`typer.main.get_command`.

Returns
-------
click.Command | None
The Click command for *obj*, or `None` when *obj* is neither a Click command nor a Typer app
(or when the required libraries are missing).
"""
try:
import click
except ImportError: # pragma: no cover - Click is a hard dependency
return None

if isinstance(obj, (click.Command, click.Group)):
return obj

try:
import typer
from typer.main import get_command
except ImportError:
return None

if isinstance(obj, typer.Typer):
try:
return get_command(obj)
except Exception: # pragma: no cover - defensive (malformed Typer app)
return None

return None


def is_cli_command(obj: Any) -> bool:
"""Return `True` when *obj* is a documentable CLI (Click command or Typer app)."""
return to_click_command(obj) is not None


# ---------------------------------------------------------------------------
# Duck-typed introspection helpers
#
# Typer vendors its own copy of Click, so a command returned by `typer.main.get_command` is *not* an
# instance of the top-level `click` package's `Command`/`Group`/`Option`/`Argument` classes. These
# helpers introspect commands structurally instead of with `isinstance` against a specific Click, so
# the same extraction code works for both plain Click CLIs and Typer apps (and any future Click
# variant).
# ---------------------------------------------------------------------------


def is_cli_group(cmd: Any) -> bool:
"""Return `True` when *cmd* is a command group (has named subcommands).

Works for both `click.Group` and Typer's vendored group, which both expose a dict-like
`commands` mapping. Leaf commands have no such mapping.
"""
commands = getattr(cmd, "commands", None)
return commands is not None and hasattr(commands, "items")


def param_kind(param: Any) -> str:
"""Return `"option"`, `"argument"`, or `""` for a Click/Typer parameter.

Uses Click's `param_type_name` attribute, which both plain Click and Typer's vendored Click set
on every parameter.
"""
return getattr(param, "param_type_name", "") or ""
Loading
Loading