1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337
|
from __future__ import annotations
from functools import cache
from typing import Any
from typing import Optional
from typing import Union
from typing import cast
import click
import typer
from pydantic import BaseModel
from pydantic import Field
from pydantic import ValidationError
from pydantic import computed_field
from pydantic import model_validator
from typer.core import TyperArgument
from typer.core import TyperCommand
from typer.core import TyperGroup
from typer.models import DefaultPlaceholder
from zabbix_cli.exceptions import ZabbixCLIError
from .markup import markup_as_plain_text
from .markup import markup_to_markdown
def get(param: Any, attr: str) -> Any:
"""Getattr that defaults to None"""
return getattr(param, attr, None)
class ParamSummary(BaseModel):
"""Serializable representation of a click.Parameter."""
allow_from_autoenv: Optional[bool] = None
confirmation_prompt: Optional[bool] = None
choices: Optional[list[str]] = None
count: Optional[bool] = None
default: Optional[Any] = None
envvar: Optional[str]
expose_value: bool
flag_value: Optional[Any] = None
help: str
hidden: Optional[bool] = None
human_readable_name: str
is_argument: bool
is_eager: bool = False
is_bool_flag: Optional[bool] = None
is_option: Optional[bool]
max: Optional[int] = None
min: Optional[int] = None
metavar: Optional[str]
multiple: bool
name: Optional[str]
nargs: int
opts: list[str]
prompt: Optional[str] = None
prompt_required: Optional[bool] = None
required: bool
secondary_opts: list[str] = []
show_choices: Optional[bool] = None
show_default: Optional[bool] = None
show_envvar: Optional[bool] = None
type: str
@classmethod
def from_param(cls, param: click.Parameter) -> ParamSummary:
"""Construct a new ParamSummary from a click.Parameter."""
try:
help_ = param.help or "" # type: ignore
except AttributeError:
help_ = ""
is_argument = isinstance(param, (click.Argument, TyperArgument))
return cls(
allow_from_autoenv=get(param, "allow_from_autoenv"),
confirmation_prompt=get(param, "confirmation_prompt"),
count=get(param, "count"),
choices=get(param.type, "choices"),
default=param.default,
envvar=param.envvar, # TODO: support list of envvars
expose_value=param.expose_value,
flag_value=get(param, "flag_value"),
help=help_,
hidden=get(param, "hidden"),
human_readable_name=param.human_readable_name,
is_argument=is_argument,
is_bool_flag=get(param, "is_bool_flag"),
is_eager=param.is_eager,
is_option=get(param, "is_option"),
max=get(param.type, "max"),
min=get(param.type, "min"),
metavar=param.metavar,
multiple=param.multiple,
name=param.name,
nargs=param.nargs,
opts=param.opts,
prompt=get(param, "prompt"),
prompt_required=get(param, "prompt_required"),
required=param.required,
secondary_opts=param.secondary_opts,
show_choices=get(param, "show_choices"),
show_default=get(param, "show_default"),
show_envvar=get(param, "show_envvar"),
type=param.type.name,
)
@property
def help_plain(self) -> str:
return markup_as_plain_text(self.help)
@property
def help_md(self) -> str:
return markup_to_markdown(self.help)
@model_validator(mode="before")
@classmethod
def _fmt_metavar(cls, data: Any) -> Any:
if isinstance(data, dict):
metavar = data.get("metavar", "") or data.get( # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
"human_readable_name", ""
)
assert isinstance(metavar, str), "metavar must be a string"
metavar = metavar.upper()
if data.get("multiple"): # pyright: ignore[reportUnknownMemberType]
new_metavar = f"<{metavar},[{metavar}...]>"
else:
new_metavar = f"<{metavar}>"
data["metavar"] = new_metavar
return data # pyright: ignore[reportUnknownVariableType]
@computed_field
@property
def show(self) -> bool:
if self.hidden:
return False
if "deprecated" in self.help.lower():
return False
return True
# TODO: split up CommandSummary into CommandSummary and CommandSearchResult
# so that the latter can have the score field
class CommandSummary(BaseModel):
"""Convenience class for accessing information about a command."""
category: Optional[str] = None # not part of TyperCommand
deprecated: bool
epilog: Optional[str]
help: str
hidden: bool
name: str
options_metavar: str
params: list[ParamSummary] = Field([], exclude=True)
score: int = 0 # match score (not part of TyperCommand)
short_help: Optional[str]
@model_validator(mode="before")
@classmethod
def _replace_placeholders(cls, values: Any) -> Any:
"""Replace DefaultPlaceholder values with empty strings."""
if not isinstance(values, dict):
return values
values = cast(dict[str, Any], values)
for key, value in values.items():
if isinstance(value, DefaultPlaceholder):
# Use its value, otherwise empty string
values[key] = value.value or ""
return values
@classmethod
def from_command(
cls, command: TyperCommand, name: str | None = None, category: str | None = None
) -> CommandSummary:
"""Construct a new CommandSummary from a TyperCommand."""
try:
return cls(
category=category,
deprecated=command.deprecated,
epilog=command.epilog or "",
help=command.help or "",
hidden=command.hidden,
name=name or command.name or "",
options_metavar=command.options_metavar or "",
params=[ParamSummary.from_param(p) for p in command.params],
short_help=command.short_help or "",
)
except ValidationError as e:
raise ZabbixCLIError(
f"Failed to construct command summary for {name or command.name}: {e}"
) from e
@property
def help_plain(self) -> str:
return markup_as_plain_text(self.help)
@property
def help_md(self) -> str:
return markup_to_markdown(self.help)
@computed_field
@property
def usage(self) -> str:
parts = [self.name]
# Assume arg list is sorted by required/optional
# `<POSITIONAL_ARG1> <POSITIONAL_ARG2> [OPTIONAL_ARG1] [OPTIONAL_ARG2]`
for arg in self.arguments:
metavar = arg.metavar or arg.human_readable_name
parts.append(metavar)
# Command with both required and optional options:
# `--option1 <opt1> --option2 <opt2> [OPTIONS]`
has_optional = False
for option in self.options:
if option.required:
metavar = option.metavar or option.human_readable_name
if option.opts:
s = f"{max(option.opts)} {metavar}"
else:
# this shouldn't happen, but just in case. A required
# option without any opts is not very useful.
# NOTE: could raise exception here instead
s = metavar
parts.append(s)
else:
has_optional = True
if has_optional:
parts.append("[OPTIONS]")
return " ".join(parts)
@computed_field
@property
def options(self) -> list[ParamSummary]:
return [p for p in self.params if _include_opt(p)]
@computed_field
@property
def arguments(self) -> list[ParamSummary]:
return [p for p in self.params if _include_arg(p)]
def _include_arg(arg: ParamSummary) -> bool:
"""Determine if an argument or option should be included in the help output."""
if not arg.is_argument:
return False
return arg.show
def _include_opt(opt: ParamSummary) -> bool:
"""Determine if an argument or option should be included in the help output."""
if opt.is_argument:
return False
return opt.show
def get_parent_ctx(
ctx: typer.Context | click.core.Context,
) -> typer.Context | click.core.Context:
"""Get the top-level parent context of a context."""
if ctx.parent is None:
return ctx
return get_parent_ctx(ctx.parent)
def get_command_help(command: typer.models.CommandInfo) -> str:
"""Get the help text of a command."""
if command.help:
return command.help
if command.callback and command.callback.__doc__:
lines = command.callback.__doc__.strip().splitlines()
if lines:
return lines[0]
if command.short_help:
return command.short_help
return ""
@cache
def get_app_commands(app: typer.Typer) -> list[CommandSummary]:
"""Get a list of commands from a typer app."""
return _get_app_commands(app)
def _get_app_commands(
app: typer.Typer,
cmds: list[CommandSummary] | None = None,
) -> list[CommandSummary]:
if cmds is None:
cmds = []
# NOTE: incorrect type annotation for get_command() here:
# The function can return either a TyperGroup or click.Command
cmd = typer.main.get_command(app)
cmd = cast(Union[TyperGroup, click.Command], cmd)
groups: dict[str, TyperCommand] = {}
try:
groups = cmd.commands # type: ignore
except AttributeError:
pass
# If we have subcommands, we need to go deeper.
for command in groups.values():
if command.deprecated: # skip deprecated commands
continue
category = command.rich_help_panel
# rich_help_panel can also be a DefaultPlaceholder
# even if the type annotation says it's str | None
if category and not isinstance(category, str): # pyright: ignore[reportUnnecessaryIsInstance]
raise ValueError(f"{command.name} is missing a rich_help_panel (category)")
cmds.append(
CommandSummary.from_command(command, name=command.name, category=category)
)
return sorted(cmds, key=lambda x: x.name)
def get_app_callback_options(app: typer.Typer) -> list[typer.models.OptionInfo]:
"""Get the options of the main callback of a Typer app."""
options: list[typer.models.OptionInfo] = []
if not app.registered_callback:
return options
callback = app.registered_callback.callback
if not callback:
return options
if not hasattr(callback, "__defaults__") or not callback.__defaults__:
return options
for option in callback.__defaults__:
options.append(option)
return options
|