nicegui-autoform 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nicegui_autoform/__init__.py +48 -0
- nicegui_autoform/_compat.py +51 -0
- nicegui_autoform/_introspect.py +99 -0
- nicegui_autoform/adapters/__init__.py +60 -0
- nicegui_autoform/adapters/_argparse.py +133 -0
- nicegui_autoform/adapters/_click.py +215 -0
- nicegui_autoform/adapters/_cyclopts.py +158 -0
- nicegui_autoform/adapters/_typer.py +28 -0
- nicegui_autoform/adapters/plain.py +150 -0
- nicegui_autoform/form.py +251 -0
- nicegui_autoform/spec.py +192 -0
- nicegui_autoform/validate.py +79 -0
- nicegui_autoform/values.py +103 -0
- nicegui_autoform/widgets.py +242 -0
- nicegui_autoform-0.1.0.dist-info/METADATA +206 -0
- nicegui_autoform-0.1.0.dist-info/RECORD +18 -0
- nicegui_autoform-0.1.0.dist-info/WHEEL +4 -0
- nicegui_autoform-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Render a NiceGUI web form from a command line interface.
|
|
2
|
+
|
|
3
|
+
Point :class:`AutoForm` at a CLI you already have -- a cyclopts ``App``, a click
|
|
4
|
+
``Command``, a ``typer.Typer``, an ``argparse.ArgumentParser``, or a plain
|
|
5
|
+
dataclass or annotated function -- and it builds a form from that CLI's own
|
|
6
|
+
parameter metadata and calls the same function on submit.
|
|
7
|
+
|
|
8
|
+
from nicegui import ui
|
|
9
|
+
from nicegui_autoform import AutoForm
|
|
10
|
+
|
|
11
|
+
AutoForm(app, command="train")
|
|
12
|
+
ui.run()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .adapters import Adapter, adapters, build_spec, register_adapter
|
|
16
|
+
from .form import AutoForm
|
|
17
|
+
from .spec import (
|
|
18
|
+
MISSING,
|
|
19
|
+
CommandSpec,
|
|
20
|
+
ContainerSpec,
|
|
21
|
+
ExcludeFromAutoform,
|
|
22
|
+
ParamSpec,
|
|
23
|
+
WidgetKind,
|
|
24
|
+
)
|
|
25
|
+
from .validate import collect_errors
|
|
26
|
+
from .values import from_widget, to_widget
|
|
27
|
+
from .widgets import choose_widget
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"MISSING",
|
|
33
|
+
"Adapter",
|
|
34
|
+
"AutoForm",
|
|
35
|
+
"CommandSpec",
|
|
36
|
+
"ContainerSpec",
|
|
37
|
+
"ExcludeFromAutoform",
|
|
38
|
+
"ParamSpec",
|
|
39
|
+
"WidgetKind",
|
|
40
|
+
"__version__",
|
|
41
|
+
"adapters",
|
|
42
|
+
"build_spec",
|
|
43
|
+
"choose_widget",
|
|
44
|
+
"collect_errors",
|
|
45
|
+
"from_widget",
|
|
46
|
+
"register_adapter",
|
|
47
|
+
"to_widget",
|
|
48
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Version shims for the optional CLI frameworks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .spec import MISSING
|
|
8
|
+
|
|
9
|
+
#: Params Typer injects into every app unless ``add_completion=False``.
|
|
10
|
+
TYPER_INJECTED_PARAMS = frozenset({"install_completion", "show_completion"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def click_default(param: Any) -> Any:
|
|
14
|
+
"""A click parameter's declared default, normalised to :data:`MISSING`.
|
|
15
|
+
|
|
16
|
+
Read straight off ``param.default`` rather than through
|
|
17
|
+
``param.get_default(ctx)``: Typer subclasses its own vendored copy of click,
|
|
18
|
+
so a ``click.Context`` built from the installed click is not the context a
|
|
19
|
+
``TyperOption`` expects, and on some click versions the mismatch silently
|
|
20
|
+
yields UNSET for a parameter that plainly has a default.
|
|
21
|
+
|
|
22
|
+
click 8.5 reports an unset default as ``Sentinel.UNSET`` where older
|
|
23
|
+
versions report ``None``. ``None`` is a legal default, so the two stay
|
|
24
|
+
distinguishable wherever click gives us enough to tell them apart.
|
|
25
|
+
"""
|
|
26
|
+
default = getattr(param, "default", None)
|
|
27
|
+
if callable(default):
|
|
28
|
+
# click allows a zero-argument callable as a default.
|
|
29
|
+
try:
|
|
30
|
+
default = default()
|
|
31
|
+
except Exception:
|
|
32
|
+
return MISSING
|
|
33
|
+
return MISSING if _is_click_unset(default) else default
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_click_unset(value: Any) -> bool:
|
|
37
|
+
if value is None:
|
|
38
|
+
return False
|
|
39
|
+
sentinel = _click_unset_sentinel()
|
|
40
|
+
if sentinel is not None and value is sentinel:
|
|
41
|
+
return True
|
|
42
|
+
# Fall back to a structural check so an unknown click release still works.
|
|
43
|
+
return type(value).__name__ == "Sentinel" and repr(value) == "Sentinel.UNSET"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _click_unset_sentinel() -> Any:
|
|
47
|
+
try:
|
|
48
|
+
from click import core
|
|
49
|
+
except ImportError: # pragma: no cover - click is an optional extra
|
|
50
|
+
return None
|
|
51
|
+
return getattr(core, "UNSET", None)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Typing helpers shared by the adapters.
|
|
2
|
+
|
|
3
|
+
These answer the questions every adapter has to ask of a type hint: what is the
|
|
4
|
+
scalar type underneath, is it a collection, what are its choices.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import types
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Annotated, Any, Literal, Union, get_args, get_origin
|
|
13
|
+
|
|
14
|
+
from .spec import ExcludeFromAutoform
|
|
15
|
+
|
|
16
|
+
_COLLECTION_ORIGINS = (list, set, frozenset, tuple, Sequence)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def unwrap_annotated(hint: Any) -> Any:
|
|
20
|
+
"""Strip ``Annotated[...]`` wrappers down to the underlying type."""
|
|
21
|
+
while get_origin(hint) is Annotated:
|
|
22
|
+
hint = get_args(hint)[0]
|
|
23
|
+
return hint
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def annotated_metadata(hint: Any) -> tuple[Any, ...]:
|
|
27
|
+
"""Every piece of ``Annotated`` metadata, outermost wrapper first."""
|
|
28
|
+
meta: list[Any] = []
|
|
29
|
+
while get_origin(hint) is Annotated:
|
|
30
|
+
meta.extend(hint.__metadata__)
|
|
31
|
+
hint = get_args(hint)[0]
|
|
32
|
+
return tuple(meta)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def has_exclude_marker(hint: Any) -> bool:
|
|
36
|
+
return any(
|
|
37
|
+
m is ExcludeFromAutoform or isinstance(m, ExcludeFromAutoform)
|
|
38
|
+
for m in annotated_metadata(hint)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strip_optional(hint: Any) -> Any:
|
|
43
|
+
"""Reduce ``X | None`` / ``Optional[X]`` to ``X``.
|
|
44
|
+
|
|
45
|
+
A union of several real types reduces to its first member, which is what the
|
|
46
|
+
original autoform did and what a single form field can meaningfully offer.
|
|
47
|
+
"""
|
|
48
|
+
if get_origin(hint) in (Union, types.UnionType):
|
|
49
|
+
args = [a for a in get_args(hint) if a is not type(None)]
|
|
50
|
+
if args:
|
|
51
|
+
return args[0]
|
|
52
|
+
return hint
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_collection(hint: Any) -> bool:
|
|
56
|
+
"""True for ``list[str]``, ``tuple[int, ...]``, ``set[str]`` and friends."""
|
|
57
|
+
origin = get_origin(hint)
|
|
58
|
+
if origin is None:
|
|
59
|
+
return hint in _COLLECTION_ORIGINS
|
|
60
|
+
return origin in _COLLECTION_ORIGINS or (
|
|
61
|
+
isinstance(origin, type) and issubclass(origin, _COLLECTION_ORIGINS)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def element_type(hint: Any) -> Any:
|
|
66
|
+
"""The element type of a collection hint, or ``str`` if unparameterised."""
|
|
67
|
+
args = [a for a in get_args(hint) if a is not Ellipsis]
|
|
68
|
+
return strip_optional(unwrap_annotated(args[0])) if args else str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def choices_of(hint: Any) -> tuple[str, ...] | None:
|
|
72
|
+
"""Choice strings for a ``Literal`` or ``Enum`` hint, else ``None``."""
|
|
73
|
+
hint = strip_optional(unwrap_annotated(hint))
|
|
74
|
+
if get_origin(hint) is Literal:
|
|
75
|
+
return tuple(str(a) for a in get_args(hint))
|
|
76
|
+
if isinstance(hint, type) and issubclass(hint, enum.Enum):
|
|
77
|
+
return tuple(m.name for m in hint)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve(hint: Any) -> tuple[Any, bool]:
|
|
82
|
+
"""Reduce a hint to ``(scalar_type, is_multiple)``.
|
|
83
|
+
|
|
84
|
+
``list[Path]`` becomes ``(Path, True)``; ``str | None`` becomes
|
|
85
|
+
``(str, False)``. Unresolvable hints fall back to ``str``, which renders as a
|
|
86
|
+
text input rather than failing.
|
|
87
|
+
"""
|
|
88
|
+
hint = strip_optional(unwrap_annotated(hint))
|
|
89
|
+
multiple = False
|
|
90
|
+
if is_collection(hint):
|
|
91
|
+
multiple = True
|
|
92
|
+
hint = element_type(hint)
|
|
93
|
+
if get_origin(hint) is Literal:
|
|
94
|
+
# A Literal renders as a choice list; its scalar type is that of its members.
|
|
95
|
+
args = get_args(hint)
|
|
96
|
+
return (type(args[0]) if args else str), multiple
|
|
97
|
+
if hint is Any or hint is None or hint is type(None):
|
|
98
|
+
return str, multiple
|
|
99
|
+
return hint, multiple
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Framework adapters and the registry that dispatches between them.
|
|
2
|
+
|
|
3
|
+
An adapter is any object exposing ``name``, ``matches(target)`` and
|
|
4
|
+
``build(target, command)``. The bundled ones are modules. Adapters never import
|
|
5
|
+
their framework at module scope: :func:`matches` first checks whether the
|
|
6
|
+
framework is even in ``sys.modules``, because a caller cannot be holding a
|
|
7
|
+
cyclopts ``App`` without cyclopts having been imported.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from ..spec import CommandSpec
|
|
15
|
+
from . import _argparse, _click, _cyclopts, _typer, plain
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class Adapter(Protocol):
|
|
20
|
+
"""What :func:`register_adapter` accepts."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
def matches(self, target: Any) -> bool: ...
|
|
25
|
+
|
|
26
|
+
def build(self, target: Any, command: str | None = None) -> CommandSpec: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Order matters: typer before click, since a Typer app resolves *to* a click
|
|
30
|
+
# command; plain last, as the catch-all for dataclasses and bare functions.
|
|
31
|
+
_ADAPTERS: list[Any] = [_cyclopts, _typer, _click, _argparse, plain]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_adapter(adapter: Any, *, first: bool = True) -> None:
|
|
35
|
+
"""Add a custom adapter. Registered first by default, so it can take
|
|
36
|
+
precedence over a bundled one."""
|
|
37
|
+
for attr in ("name", "matches", "build"):
|
|
38
|
+
if not hasattr(adapter, attr):
|
|
39
|
+
raise TypeError(f"adapter is missing required attribute {attr!r}")
|
|
40
|
+
_ADAPTERS.insert(0, adapter) if first else _ADAPTERS.append(adapter)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def adapters() -> tuple[Any, ...]:
|
|
44
|
+
"""The registry, in dispatch order."""
|
|
45
|
+
return tuple(_ADAPTERS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_spec(target: Any, command: str | None = None) -> CommandSpec:
|
|
49
|
+
"""Introspect *target* into a :class:`~nicegui_autoform.spec.CommandSpec`."""
|
|
50
|
+
for adapter in _ADAPTERS:
|
|
51
|
+
if adapter.matches(target):
|
|
52
|
+
return adapter.build(target, command)
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"no adapter can handle {target!r} (type {type(target).__name__}). "
|
|
55
|
+
"Expected a cyclopts App, click Command, Typer app, ArgumentParser, "
|
|
56
|
+
"dataclass or annotated function."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["Adapter", "adapters", "build_spec", "register_adapter"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Adapter for ``argparse``.
|
|
2
|
+
|
|
3
|
+
``ArgumentParser`` keeps its actions on the private ``_actions`` list, which has
|
|
4
|
+
been stable across the whole life of the module. Unlike the other frameworks an
|
|
5
|
+
``ArgumentParser`` has no callback -- parsing produces a ``Namespace`` and the
|
|
6
|
+
program takes it from there -- so :attr:`CommandSpec.callback` is ``None`` and
|
|
7
|
+
:class:`~nicegui_autoform.form.AutoForm` requires an explicit ``on_submit``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..spec import MISSING, CommandSpec, ParamSpec
|
|
17
|
+
|
|
18
|
+
name = "argparse"
|
|
19
|
+
|
|
20
|
+
_SKIPPED_ACTIONS = (argparse._HelpAction, argparse._VersionAction, argparse._SubParsersAction)
|
|
21
|
+
_FLAG_ACTIONS = (argparse._StoreTrueAction, argparse._StoreFalseAction)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def matches(target: Any) -> bool:
|
|
25
|
+
return isinstance(target, argparse.ArgumentParser)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build(target: Any, command: str | None = None) -> CommandSpec:
|
|
29
|
+
parser = _resolve_command(target, command)
|
|
30
|
+
sections = _sections(parser)
|
|
31
|
+
|
|
32
|
+
params = []
|
|
33
|
+
for action in parser._actions:
|
|
34
|
+
if isinstance(action, _SKIPPED_ACTIONS) or action.help == argparse.SUPPRESS:
|
|
35
|
+
continue
|
|
36
|
+
if action.dest == argparse.SUPPRESS:
|
|
37
|
+
continue
|
|
38
|
+
params.append(_to_param(action, sections.get(id(action))))
|
|
39
|
+
|
|
40
|
+
return CommandSpec(
|
|
41
|
+
name=parser.prog or "",
|
|
42
|
+
help=_first_line(parser.description),
|
|
43
|
+
params=tuple(params),
|
|
44
|
+
callback=None,
|
|
45
|
+
source=name,
|
|
46
|
+
extras={"parser": parser},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_command(parser: Any, command: str | None) -> Any:
|
|
51
|
+
subparsers = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)]
|
|
52
|
+
if command is None:
|
|
53
|
+
return parser
|
|
54
|
+
for action in subparsers:
|
|
55
|
+
if command in action.choices:
|
|
56
|
+
return action.choices[command]
|
|
57
|
+
available = sorted({name for a in subparsers for name in a.choices})
|
|
58
|
+
raise KeyError(f"{command!r} is not a subcommand of this parser; available: {available}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sections(parser: Any) -> dict[int, str]:
|
|
62
|
+
"""Map each action to its argument group title, skipping argparse's defaults."""
|
|
63
|
+
default_titles = {"positional arguments", "options", "optional arguments"}
|
|
64
|
+
sections: dict[int, str] = {}
|
|
65
|
+
for group in parser._action_groups:
|
|
66
|
+
title = (group.title or "").strip()
|
|
67
|
+
if not title or title.lower() in default_titles:
|
|
68
|
+
continue
|
|
69
|
+
for action in group._group_actions:
|
|
70
|
+
sections[id(action)] = title
|
|
71
|
+
return sections
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _to_param(action: Any, section: str | None) -> ParamSpec:
|
|
75
|
+
is_flag = isinstance(action, _FLAG_ACTIONS)
|
|
76
|
+
scalar = _resolve_type(action, is_flag)
|
|
77
|
+
multiple = (
|
|
78
|
+
isinstance(action, argparse._AppendAction)
|
|
79
|
+
or action.nargs in ("+", "*")
|
|
80
|
+
or (isinstance(action.nargs, int) and action.nargs > 1)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
positional = not action.option_strings
|
|
84
|
+
required = bool(action.required) or (positional and action.nargs not in ("?", "*"))
|
|
85
|
+
default = MISSING if (required or action.default is None and positional) else action.default
|
|
86
|
+
if default is argparse.SUPPRESS:
|
|
87
|
+
default = MISSING
|
|
88
|
+
|
|
89
|
+
choices = tuple(str(c) for c in action.choices) if action.choices else None
|
|
90
|
+
|
|
91
|
+
return ParamSpec(
|
|
92
|
+
name=action.dest,
|
|
93
|
+
path=(action.dest,),
|
|
94
|
+
cli_name=action.option_strings[0] if action.option_strings else action.dest,
|
|
95
|
+
annotation=action.type,
|
|
96
|
+
type=scalar,
|
|
97
|
+
required=required,
|
|
98
|
+
default=default,
|
|
99
|
+
help=_clean(action.help),
|
|
100
|
+
choices=choices,
|
|
101
|
+
multiple=bool(multiple),
|
|
102
|
+
nargs=action.nargs if isinstance(action.nargs, int) and action.nargs > 1 else None,
|
|
103
|
+
is_flag=is_flag,
|
|
104
|
+
section=section,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_type(action: Any, is_flag: bool) -> Any:
|
|
109
|
+
if is_flag:
|
|
110
|
+
return bool
|
|
111
|
+
converter = action.type
|
|
112
|
+
if converter is None:
|
|
113
|
+
return str
|
|
114
|
+
# FileType is deprecated but still widely used, so the adapter has to keep
|
|
115
|
+
# mapping it to a path.
|
|
116
|
+
if isinstance(converter, argparse.FileType): # ty: ignore[deprecated]
|
|
117
|
+
return Path
|
|
118
|
+
if isinstance(converter, type):
|
|
119
|
+
return converter
|
|
120
|
+
# A plain callable converter (e.g. a lambda) tells us nothing useful; a text
|
|
121
|
+
# input feeding the callable is the honest rendering.
|
|
122
|
+
return str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _clean(text: str | None) -> str | None:
|
|
126
|
+
return " ".join(text.split()) if text else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _first_line(text: str | None) -> str | None:
|
|
130
|
+
if not text:
|
|
131
|
+
return None
|
|
132
|
+
stripped = text.strip()
|
|
133
|
+
return stripped.split("\n\n")[0].strip() or None
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Adapter for click.
|
|
2
|
+
|
|
3
|
+
``Command.params`` gives fully resolved ``Option``/``Argument`` objects. The one
|
|
4
|
+
sharp edge is the default sentinel: click 8.5 reports an unset default as
|
|
5
|
+
``click.core.UNSET`` rather than ``None``, which :mod:`nicegui_autoform._compat`
|
|
6
|
+
normalises.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import enum
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, get_type_hints
|
|
16
|
+
|
|
17
|
+
from .._compat import TYPER_INJECTED_PARAMS, click_default
|
|
18
|
+
from .._introspect import resolve
|
|
19
|
+
from ..spec import MISSING, CommandSpec, ParamSpec
|
|
20
|
+
|
|
21
|
+
name = "click"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def matches(target: Any) -> bool:
|
|
25
|
+
click = sys.modules.get("click")
|
|
26
|
+
return click is not None and isinstance(target, click.Command)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build(target: Any, command: str | None = None, *, source: str = name) -> CommandSpec:
|
|
30
|
+
cmd = _resolve_command(target, command)
|
|
31
|
+
|
|
32
|
+
params = []
|
|
33
|
+
for param in cmd.params:
|
|
34
|
+
if getattr(param, "hidden", False) or param.name in TYPER_INJECTED_PARAMS:
|
|
35
|
+
continue
|
|
36
|
+
params.append(_to_param(param))
|
|
37
|
+
|
|
38
|
+
return CommandSpec(
|
|
39
|
+
name=cmd.name or "",
|
|
40
|
+
help=_first_line(cmd.help),
|
|
41
|
+
params=_enrich_from_callback(tuple(params), cmd.callback),
|
|
42
|
+
callback=cmd.callback,
|
|
43
|
+
source=source,
|
|
44
|
+
extras={"click_command": cmd},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _enrich_from_callback(params: tuple[ParamSpec, ...], callback: Any) -> tuple[ParamSpec, ...]:
|
|
49
|
+
"""Recover types that the click ``ParamType`` could not express.
|
|
50
|
+
|
|
51
|
+
Typer's ``TyperChoice`` keeps only the choice strings, discarding the Enum
|
|
52
|
+
class the callback actually expects -- but the callback's own annotation
|
|
53
|
+
still has it. Since we call that callback directly, its annotation is the
|
|
54
|
+
authority whenever click could only tell us ``str``.
|
|
55
|
+
"""
|
|
56
|
+
if callback is None:
|
|
57
|
+
return params
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
hints = get_type_hints(callback)
|
|
61
|
+
except Exception: # unresolvable forward refs; the click types stand alone
|
|
62
|
+
return params
|
|
63
|
+
|
|
64
|
+
enriched = []
|
|
65
|
+
for param in params:
|
|
66
|
+
hint = hints.get(param.name)
|
|
67
|
+
if hint is None:
|
|
68
|
+
enriched.append(param)
|
|
69
|
+
continue
|
|
70
|
+
scalar, multiple = resolve(hint)
|
|
71
|
+
is_enum = isinstance(scalar, type) and issubclass(scalar, enum.Enum)
|
|
72
|
+
if is_enum or param.type is str:
|
|
73
|
+
enriched.append(
|
|
74
|
+
replace(param, type=scalar, annotation=hint, multiple=param.multiple or multiple)
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
enriched.append(param)
|
|
78
|
+
return tuple(enriched)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_command(target: Any, command: str | None) -> Any:
|
|
82
|
+
"""Narrow a group to a single command.
|
|
83
|
+
|
|
84
|
+
Groups are identified by having a ``commands`` mapping rather than by
|
|
85
|
+
``isinstance(target, click.Group)``: Typer's ``TyperGroup`` subclasses its
|
|
86
|
+
own vendored click, so the isinstance check would quietly treat a group as a
|
|
87
|
+
single command.
|
|
88
|
+
"""
|
|
89
|
+
subcommands = getattr(target, "commands", None)
|
|
90
|
+
if not subcommands:
|
|
91
|
+
if command is not None and command != target.name:
|
|
92
|
+
raise KeyError(
|
|
93
|
+
f"{target.name!r} is a single command, not a group containing {command!r}"
|
|
94
|
+
)
|
|
95
|
+
return target
|
|
96
|
+
|
|
97
|
+
names = [n for n, c in subcommands.items() if not getattr(c, "hidden", False)]
|
|
98
|
+
if command is not None:
|
|
99
|
+
if command not in subcommands:
|
|
100
|
+
raise KeyError(f"{command!r} is not a command of this group; available: {names}")
|
|
101
|
+
return subcommands[command]
|
|
102
|
+
if len(names) == 1:
|
|
103
|
+
return subcommands[names[0]]
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"this group has {len(names)} commands, so command= is required; available: {names}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _to_param(param: Any) -> ParamSpec:
|
|
110
|
+
scalar, choices, minimum, maximum = _resolve_type(param.type)
|
|
111
|
+
default = click_default(param)
|
|
112
|
+
nargs = param.nargs if isinstance(param.nargs, int) else None
|
|
113
|
+
multiple = bool(param.multiple) or (nargs is not None and nargs != 1)
|
|
114
|
+
|
|
115
|
+
if multiple and (default is MISSING or default is None or default in ((), [])):
|
|
116
|
+
# Parsing a multiple option that was never passed yields an empty tuple,
|
|
117
|
+
# never None -- so an empty list is the honest starting value however
|
|
118
|
+
# this click version reports the unset default (UNSET since 8.5, None
|
|
119
|
+
# before that).
|
|
120
|
+
default = [] if not param.required else MISSING
|
|
121
|
+
if param.required:
|
|
122
|
+
default = MISSING
|
|
123
|
+
|
|
124
|
+
opts = list(param.opts or [])
|
|
125
|
+
secondary = list(param.secondary_opts or [])
|
|
126
|
+
|
|
127
|
+
return ParamSpec(
|
|
128
|
+
name=param.name,
|
|
129
|
+
path=(param.name,),
|
|
130
|
+
cli_name=opts[0] if opts else param.name,
|
|
131
|
+
annotation=param.type,
|
|
132
|
+
type=scalar,
|
|
133
|
+
required=bool(param.required),
|
|
134
|
+
default=default,
|
|
135
|
+
help=_clean(getattr(param, "help", None)),
|
|
136
|
+
choices=choices,
|
|
137
|
+
multiple=multiple,
|
|
138
|
+
nargs=nargs if nargs not in (None, 1, -1) else None,
|
|
139
|
+
is_flag=bool(getattr(param, "is_flag", False)) or scalar is bool,
|
|
140
|
+
negative_cli_name=secondary[0] if secondary else None,
|
|
141
|
+
section=getattr(param, "rich_help_panel", None),
|
|
142
|
+
env_var=_env_vars(param),
|
|
143
|
+
minimum=minimum,
|
|
144
|
+
maximum=maximum,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _resolve_type(
|
|
149
|
+
param_type: Any,
|
|
150
|
+
) -> tuple[Any, tuple[str, ...] | None, float | None, float | None]:
|
|
151
|
+
"""Map a click ``ParamType`` to ``(python type, choices, minimum, maximum)``.
|
|
152
|
+
|
|
153
|
+
Deliberately duck-typed rather than ``isinstance``-based: Typer ships its own
|
|
154
|
+
vendored copy of click, so ``typer.models.TyperPath`` is not a
|
|
155
|
+
``click.Path`` and its scalar types report ``name='int'`` where real click
|
|
156
|
+
reports ``'integer'``. Matching on the MRO's class names plus the type's own
|
|
157
|
+
``name`` covers both, and keeps working across click releases.
|
|
158
|
+
"""
|
|
159
|
+
class_names = " ".join(c.__name__ for c in type(param_type).__mro__)
|
|
160
|
+
type_name = (getattr(param_type, "name", "") or "").lower()
|
|
161
|
+
|
|
162
|
+
choices = getattr(param_type, "choices", None)
|
|
163
|
+
if choices is not None:
|
|
164
|
+
return str, tuple(_choice_label(c) for c in choices), None, None
|
|
165
|
+
|
|
166
|
+
if "IntRange" in class_names or type_name == "integer range":
|
|
167
|
+
return int, None, _bound(param_type, "min"), _bound(param_type, "max")
|
|
168
|
+
if "FloatRange" in class_names or type_name == "float range":
|
|
169
|
+
return float, None, _bound(param_type, "min"), _bound(param_type, "max")
|
|
170
|
+
if "Path" in class_names or "File" in class_names or type_name in {"path", "filename", "file"}:
|
|
171
|
+
return Path, None, None, None
|
|
172
|
+
if "DateTime" in class_names or type_name == "datetime":
|
|
173
|
+
return str, None, None, None
|
|
174
|
+
if "Bool" in class_names or type_name in {"boolean", "bool"}:
|
|
175
|
+
return bool, None, None, None
|
|
176
|
+
if "Int" in class_names or type_name in {"integer", "int"}:
|
|
177
|
+
return int, None, None, None
|
|
178
|
+
if "Float" in class_names or type_name == "float":
|
|
179
|
+
return float, None, None, None
|
|
180
|
+
return str, None, None, None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _choice_label(choice: Any) -> str:
|
|
184
|
+
"""The string a choice is shown and submitted as.
|
|
185
|
+
|
|
186
|
+
``click.Choice`` accepts an Enum directly and keeps its members, whereas
|
|
187
|
+
Typer flattens an Enum to its values before click ever sees it. Preferring a
|
|
188
|
+
string ``.value`` makes both produce the same labels.
|
|
189
|
+
"""
|
|
190
|
+
if isinstance(choice, enum.Enum):
|
|
191
|
+
return choice.value if isinstance(choice.value, str) else choice.name
|
|
192
|
+
return str(choice)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _bound(param_type: Any, attr: str) -> float | None:
|
|
196
|
+
value = getattr(param_type, attr, None)
|
|
197
|
+
return float(value) if value is not None else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _env_vars(param: Any) -> tuple[str, ...]:
|
|
201
|
+
envvar = getattr(param, "envvar", None)
|
|
202
|
+
if not envvar:
|
|
203
|
+
return ()
|
|
204
|
+
return (envvar,) if isinstance(envvar, str) else tuple(envvar)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _clean(text: str | None) -> str | None:
|
|
208
|
+
return " ".join(text.split()) if text else None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _first_line(text: str | None) -> str | None:
|
|
212
|
+
if not text:
|
|
213
|
+
return None
|
|
214
|
+
stripped = text.strip()
|
|
215
|
+
return stripped.split("\n\n")[0].strip() or None
|