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.
@@ -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