richforms 0.3.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.
- richforms/__init__.py +15 -0
- richforms/api.py +273 -0
- richforms/cli.py +91 -0
- richforms/config.py +52 -0
- richforms/drafts.py +40 -0
- richforms/example/__init__.py +3 -0
- richforms/example/model.py +58 -0
- richforms/exceptions.py +6 -0
- richforms/integrations/__init__.py +4 -0
- richforms/integrations/click.py +24 -0
- richforms/integrations/typer.py +20 -0
- richforms/introspection.py +116 -0
- richforms/io.py +18 -0
- richforms/prompts.py +188 -0
- richforms/render.py +174 -0
- richforms/schema.py +33 -0
- richforms/serializers.py +34 -0
- richforms/state.py +42 -0
- richforms/validate.py +32 -0
- richforms-0.3.0.dist-info/METADATA +72 -0
- richforms-0.3.0.dist-info/RECORD +24 -0
- richforms-0.3.0.dist-info/WHEEL +4 -0
- richforms-0.3.0.dist-info/entry_points.txt +2 -0
- richforms-0.3.0.dist-info/licenses/LICENSE +21 -0
richforms/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from richforms.api import collect_dict, collect_model, edit, edit_model, fill
|
|
2
|
+
from richforms.cli import main
|
|
3
|
+
from richforms.config import FormConfig
|
|
4
|
+
from richforms.serializers import serialize_result
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"FormConfig",
|
|
8
|
+
"edit",
|
|
9
|
+
"fill",
|
|
10
|
+
"collect_dict",
|
|
11
|
+
"collect_model",
|
|
12
|
+
"edit_model",
|
|
13
|
+
"main",
|
|
14
|
+
"serialize_result",
|
|
15
|
+
]
|
richforms/api.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from typing import Any, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from richforms.config import FormConfig, Interaction, RichInteraction
|
|
11
|
+
from richforms.drafts import build_draft_path, save_draft_yaml
|
|
12
|
+
from richforms.introspection import build_model_schema
|
|
13
|
+
from richforms.prompts import prompt_for_value
|
|
14
|
+
from richforms.render import (
|
|
15
|
+
render_editor_view,
|
|
16
|
+
render_review,
|
|
17
|
+
render_validation_ledger,
|
|
18
|
+
)
|
|
19
|
+
from richforms.schema import FieldNode, ModelSchema
|
|
20
|
+
from richforms.serializers import Format, serialize_result
|
|
21
|
+
from richforms.state import (
|
|
22
|
+
copy_initial,
|
|
23
|
+
get_nested_value,
|
|
24
|
+
has_nested_value,
|
|
25
|
+
set_nested_value,
|
|
26
|
+
)
|
|
27
|
+
from richforms.validate import validate_draft
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T", bound=BaseModel)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fill(
|
|
33
|
+
model_type: type[T],
|
|
34
|
+
*,
|
|
35
|
+
initial: dict[str, Any] | None = None,
|
|
36
|
+
config: FormConfig | None = None,
|
|
37
|
+
console: Console | None = None,
|
|
38
|
+
_path_prefix: str = "",
|
|
39
|
+
_handle_interrupt: bool = True,
|
|
40
|
+
) -> T:
|
|
41
|
+
cfg = config or FormConfig()
|
|
42
|
+
resolved_console = console or cfg.console or Console()
|
|
43
|
+
interaction: Interaction = cfg.interaction or RichInteraction(resolved_console)
|
|
44
|
+
schema = build_model_schema(model_type)
|
|
45
|
+
draft = copy_initial(initial)
|
|
46
|
+
first_pass = True
|
|
47
|
+
errors: dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
while True:
|
|
51
|
+
targets = _target_nodes(schema, errors=errors, first_pass=first_pass)
|
|
52
|
+
for index, node in enumerate(targets, start=1):
|
|
53
|
+
current_value, has_default = _resolve_default(node=node, draft=draft)
|
|
54
|
+
display_path = _display_path(node.path, _path_prefix)
|
|
55
|
+
if cfg.clear_on_step and resolved_console.is_terminal:
|
|
56
|
+
resolved_console.clear()
|
|
57
|
+
cockpit = render_editor_view(
|
|
58
|
+
nodes=schema.leaf_nodes,
|
|
59
|
+
node=node,
|
|
60
|
+
index=index,
|
|
61
|
+
total=len(targets),
|
|
62
|
+
current_path=display_path,
|
|
63
|
+
error=errors.get(node.path),
|
|
64
|
+
has_default=has_default,
|
|
65
|
+
default_value=current_value,
|
|
66
|
+
path_prefix=_path_prefix,
|
|
67
|
+
completed_paths=set(
|
|
68
|
+
_prefixed_paths(_completed_paths(schema, draft), _path_prefix)
|
|
69
|
+
),
|
|
70
|
+
error_paths=set(_prefixed_paths(errors.keys(), _path_prefix)),
|
|
71
|
+
console_width=resolved_console.width,
|
|
72
|
+
)
|
|
73
|
+
resolved_console.print(cockpit)
|
|
74
|
+
value = prompt_for_value(
|
|
75
|
+
node=node,
|
|
76
|
+
interaction=interaction,
|
|
77
|
+
console=resolved_console,
|
|
78
|
+
default_value=current_value,
|
|
79
|
+
has_default=has_default,
|
|
80
|
+
prompt_path=display_path,
|
|
81
|
+
)
|
|
82
|
+
resolved_console.print()
|
|
83
|
+
set_nested_value(draft, node.path, value)
|
|
84
|
+
|
|
85
|
+
result = validate_draft(model_type, draft)
|
|
86
|
+
if result.model is not None:
|
|
87
|
+
resolved_console.print(render_review(result.model))
|
|
88
|
+
if cfg.confirm_before_return and not interaction.confirm(
|
|
89
|
+
"Accept this form submission?", default=True
|
|
90
|
+
):
|
|
91
|
+
path = interaction.ask(
|
|
92
|
+
"Enter a field path to edit (blank to keep current values):",
|
|
93
|
+
default="",
|
|
94
|
+
)
|
|
95
|
+
errors = {path: "Manual edit requested"} if path else {}
|
|
96
|
+
first_pass = False
|
|
97
|
+
continue
|
|
98
|
+
return cast(T, result.model)
|
|
99
|
+
|
|
100
|
+
errors = _normalize_errors(result.errors, schema)
|
|
101
|
+
resolved_console.print(
|
|
102
|
+
render_validation_ledger(_prefixed_error_map(errors, _path_prefix))
|
|
103
|
+
)
|
|
104
|
+
first_pass = False
|
|
105
|
+
except KeyboardInterrupt:
|
|
106
|
+
if _handle_interrupt:
|
|
107
|
+
_handle_keyboard_interrupt(
|
|
108
|
+
cfg=cfg,
|
|
109
|
+
interaction=interaction,
|
|
110
|
+
console=resolved_console,
|
|
111
|
+
model_type=model_type,
|
|
112
|
+
draft=draft,
|
|
113
|
+
)
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def edit_model(
|
|
118
|
+
instance: T,
|
|
119
|
+
*,
|
|
120
|
+
config: FormConfig | None = None,
|
|
121
|
+
console: Console | None = None,
|
|
122
|
+
) -> T:
|
|
123
|
+
warnings.warn(
|
|
124
|
+
"richforms.api.edit_model is deprecated; use richforms.api.edit instead",
|
|
125
|
+
DeprecationWarning,
|
|
126
|
+
stacklevel=2,
|
|
127
|
+
)
|
|
128
|
+
return edit(instance, config=config, console=console)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def edit(
|
|
132
|
+
instance: T,
|
|
133
|
+
*,
|
|
134
|
+
config: FormConfig | None = None,
|
|
135
|
+
console: Console | None = None,
|
|
136
|
+
) -> T:
|
|
137
|
+
return fill(
|
|
138
|
+
type(instance),
|
|
139
|
+
initial=instance.model_dump(mode="python"),
|
|
140
|
+
config=config,
|
|
141
|
+
console=console,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def collect_model(
|
|
146
|
+
model_type: type[T],
|
|
147
|
+
*,
|
|
148
|
+
initial: dict[str, Any] | None = None,
|
|
149
|
+
config: FormConfig | None = None,
|
|
150
|
+
console: Console | None = None,
|
|
151
|
+
_path_prefix: str = "",
|
|
152
|
+
_handle_interrupt: bool = True,
|
|
153
|
+
) -> T:
|
|
154
|
+
warnings.warn(
|
|
155
|
+
"richforms.api.collect_model is deprecated; use richforms.api.fill instead",
|
|
156
|
+
DeprecationWarning,
|
|
157
|
+
stacklevel=2,
|
|
158
|
+
)
|
|
159
|
+
return fill(
|
|
160
|
+
model_type,
|
|
161
|
+
initial=initial,
|
|
162
|
+
config=config,
|
|
163
|
+
console=console,
|
|
164
|
+
_path_prefix=_path_prefix,
|
|
165
|
+
_handle_interrupt=_handle_interrupt,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def collect_dict(
|
|
170
|
+
model_type: type[T],
|
|
171
|
+
*,
|
|
172
|
+
initial: dict[str, Any] | None = None,
|
|
173
|
+
config: FormConfig | None = None,
|
|
174
|
+
console: Console | None = None,
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
model = fill(model_type, initial=initial, config=config, console=console)
|
|
177
|
+
return model.model_dump(mode="python")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def serialize_model_result(
|
|
181
|
+
model: BaseModel, *, format: Format, path: str | None = None
|
|
182
|
+
) -> str | None:
|
|
183
|
+
from pathlib import Path
|
|
184
|
+
|
|
185
|
+
out_path = Path(path) if path else None
|
|
186
|
+
return serialize_result(model, format=format, path=out_path)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _target_nodes(
|
|
190
|
+
schema: ModelSchema, *, errors: dict[str, str], first_pass: bool
|
|
191
|
+
) -> list[FieldNode]:
|
|
192
|
+
if first_pass:
|
|
193
|
+
return schema.leaf_nodes
|
|
194
|
+
return [node for node in schema.leaf_nodes if node.path in errors]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _resolve_default(*, node: FieldNode, draft: dict[str, Any]) -> tuple[Any, bool]:
|
|
198
|
+
if has_nested_value(draft, node.path):
|
|
199
|
+
return get_nested_value(draft, node.path), True
|
|
200
|
+
if node.has_default:
|
|
201
|
+
return node.default, True
|
|
202
|
+
return None, False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _normalize_errors(errors: dict[str, str], schema: ModelSchema) -> dict[str, str]:
|
|
206
|
+
if not errors:
|
|
207
|
+
return {}
|
|
208
|
+
normalized: dict[str, str] = {}
|
|
209
|
+
known_paths = schema.by_path
|
|
210
|
+
for path, message in errors.items():
|
|
211
|
+
if path in known_paths:
|
|
212
|
+
normalized[path] = message
|
|
213
|
+
continue
|
|
214
|
+
for known in known_paths:
|
|
215
|
+
if known.startswith(path + "."):
|
|
216
|
+
normalized[known] = message
|
|
217
|
+
break
|
|
218
|
+
return normalized
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _completed_paths(schema: ModelSchema, draft: dict[str, Any]) -> list[str]:
|
|
222
|
+
return [node.path for node in schema.leaf_nodes if has_nested_value(draft, node.path)]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _display_path(path: str, prefix: str) -> str:
|
|
226
|
+
if not prefix:
|
|
227
|
+
return path
|
|
228
|
+
return f"{prefix}.{path}"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _prefixed_paths(paths: Iterable[str], prefix: str) -> list[str]:
|
|
232
|
+
return [_display_path(path, prefix) for path in paths]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _prefixed_error_map(errors: dict[str, str], prefix: str) -> dict[str, str]:
|
|
236
|
+
return {_display_path(path, prefix): message for path, message in errors.items()}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _handle_keyboard_interrupt(
|
|
240
|
+
*,
|
|
241
|
+
cfg: FormConfig,
|
|
242
|
+
interaction: Interaction,
|
|
243
|
+
console: Console,
|
|
244
|
+
model_type: type[BaseModel],
|
|
245
|
+
draft: dict[str, Any],
|
|
246
|
+
) -> None:
|
|
247
|
+
style = cfg.interrupt_message_style
|
|
248
|
+
if not draft:
|
|
249
|
+
console.print("Form entry interrupted.", style=style)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
save_mode = cfg.save_draft_on_interrupt
|
|
253
|
+
save_path = build_draft_path(model_type.__name__, directory=cfg.draft_directory)
|
|
254
|
+
should_save = False
|
|
255
|
+
if save_mode == "always":
|
|
256
|
+
should_save = True
|
|
257
|
+
elif save_mode == "prompt":
|
|
258
|
+
should_save = interaction.confirm(f"Save draft to {save_path}?", default=True)
|
|
259
|
+
|
|
260
|
+
if not should_save:
|
|
261
|
+
console.print("Form entry interrupted. Draft not saved.", style=style)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
save_draft_yaml(draft, path=save_path)
|
|
266
|
+
except Exception as exc: # pragma: no cover
|
|
267
|
+
console.print(f"Form entry interrupted. Failed to save draft: {exc}", style="bold red")
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
console.print(
|
|
271
|
+
f"Draft saved to {save_path}. Restart with --from-file {save_path}",
|
|
272
|
+
style=style,
|
|
273
|
+
)
|
richforms/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Any, Literal
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from richforms.api import edit as edit_form
|
|
11
|
+
from richforms.api import fill as fill_form
|
|
12
|
+
from richforms.config import FormConfig
|
|
13
|
+
from richforms.io import load_payload_file
|
|
14
|
+
from richforms.serializers import serialize_result
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Rich terminal forms powered by Pydantic models.")
|
|
17
|
+
OutputFormat = Literal["json", "yaml"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(help="Create a new form.")
|
|
21
|
+
def fill(
|
|
22
|
+
model: Annotated[str, typer.Argument(help="Model path as module:ModelName")],
|
|
23
|
+
from_file: Annotated[
|
|
24
|
+
Path | None, typer.Option("--from-file", help="Optional initial JSON/YAML file")
|
|
25
|
+
] = None,
|
|
26
|
+
output: Annotated[Path | None, typer.Option("--output", help="Output file path")] = None,
|
|
27
|
+
format: Annotated[OutputFormat, typer.Option("--format", help="Output format")] = "json",
|
|
28
|
+
clear: Annotated[
|
|
29
|
+
bool,
|
|
30
|
+
typer.Option("--clear/--no-clear", help="Clear the terminal between each field"),
|
|
31
|
+
] = True,
|
|
32
|
+
) -> None:
|
|
33
|
+
model_type = _load_model_type(model)
|
|
34
|
+
initial = load_payload_file(from_file) if from_file else None
|
|
35
|
+
config = FormConfig(clear_on_step=clear)
|
|
36
|
+
try:
|
|
37
|
+
result = fill_form(model_type, initial=initial, config=config)
|
|
38
|
+
except KeyboardInterrupt as exc:
|
|
39
|
+
typer.echo("Form entry interrupted.", err=True)
|
|
40
|
+
raise typer.Exit(code=130) from exc
|
|
41
|
+
_emit_result(result=result, output=output, format=format)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command(help="Edit a saved form.")
|
|
45
|
+
def edit(
|
|
46
|
+
model: Annotated[str, typer.Argument(help="Model path as module:ModelName")],
|
|
47
|
+
from_file: Annotated[
|
|
48
|
+
Path, typer.Option("--from-file", help="Input JSON/YAML file", exists=True)
|
|
49
|
+
],
|
|
50
|
+
output: Annotated[Path | None, typer.Option("--output", help="Output file path")] = None,
|
|
51
|
+
format: Annotated[OutputFormat, typer.Option("--format", help="Output format")] = "json",
|
|
52
|
+
clear: Annotated[
|
|
53
|
+
bool,
|
|
54
|
+
typer.Option("--clear/--no-clear", help="Clear the terminal between each field"),
|
|
55
|
+
] = True,
|
|
56
|
+
) -> None:
|
|
57
|
+
model_type = _load_model_type(model)
|
|
58
|
+
payload = load_payload_file(from_file)
|
|
59
|
+
instance = model_type.model_validate(payload)
|
|
60
|
+
config = FormConfig(clear_on_step=clear)
|
|
61
|
+
try:
|
|
62
|
+
result = edit_form(instance, config=config)
|
|
63
|
+
except KeyboardInterrupt as exc:
|
|
64
|
+
typer.echo("Form entry interrupted.", err=True)
|
|
65
|
+
raise typer.Exit(code=130) from exc
|
|
66
|
+
_emit_result(result=result, output=output, format=format)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
app()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _emit_result(*, result: BaseModel, output: Path | None, format: OutputFormat) -> None:
|
|
74
|
+
payload = serialize_result(result, format=format, path=output)
|
|
75
|
+
if output:
|
|
76
|
+
typer.echo(f"Wrote {format} output to {output}")
|
|
77
|
+
return
|
|
78
|
+
typer.echo(payload or "")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_model_type(model_path: str) -> type[BaseModel]:
|
|
82
|
+
if ":" not in model_path:
|
|
83
|
+
raise typer.BadParameter("Model must use module:ModelName format")
|
|
84
|
+
module_name, _, class_name = model_path.partition(":")
|
|
85
|
+
module = importlib.import_module(module_name)
|
|
86
|
+
model_type: Any = getattr(module, class_name, None)
|
|
87
|
+
if model_type is None:
|
|
88
|
+
raise typer.BadParameter(f"Model class not found: {class_name}")
|
|
89
|
+
if not isinstance(model_type, type) or not issubclass(model_type, BaseModel):
|
|
90
|
+
raise typer.BadParameter(f"{class_name} is not a Pydantic BaseModel")
|
|
91
|
+
return model_type
|
richforms/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal, Protocol
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.prompt import Confirm, Prompt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Interaction(Protocol):
|
|
12
|
+
def ask(self, prompt: str, default: str | None = None) -> str: ...
|
|
13
|
+
|
|
14
|
+
def confirm(self, prompt: str, default: bool = True) -> bool: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class ThemeTokens:
|
|
19
|
+
ink: str = "white"
|
|
20
|
+
alloy: str = "grey50"
|
|
21
|
+
paper: str = "white"
|
|
22
|
+
probe: str = "cyan"
|
|
23
|
+
caution: str = "yellow"
|
|
24
|
+
fault: str = "red"
|
|
25
|
+
verify: str = "green"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_PROMPT_PREFIX = "[bold cyan]▸[/bold cyan]"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RichInteraction:
|
|
32
|
+
def __init__(self, console: Console) -> None:
|
|
33
|
+
self.console = console
|
|
34
|
+
|
|
35
|
+
def ask(self, prompt: str, default: str | None = None) -> str:
|
|
36
|
+
value = Prompt.ask(f"{_PROMPT_PREFIX} {prompt}", default=default, console=self.console)
|
|
37
|
+
return value if value is not None else ""
|
|
38
|
+
|
|
39
|
+
def confirm(self, prompt: str, default: bool = True) -> bool:
|
|
40
|
+
return Confirm.ask(f"{_PROMPT_PREFIX} {prompt}", default=default, console=self.console)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class FormConfig:
|
|
45
|
+
interaction: Interaction | None = None
|
|
46
|
+
console: Console | None = None
|
|
47
|
+
theme: ThemeTokens = field(default_factory=ThemeTokens)
|
|
48
|
+
confirm_before_return: bool = True
|
|
49
|
+
save_draft_on_interrupt: Literal["prompt", "always", "never"] = "prompt"
|
|
50
|
+
draft_directory: Path | None = None
|
|
51
|
+
interrupt_message_style: str = "yellow"
|
|
52
|
+
clear_on_step: bool = True
|
richforms/drafts.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import NamedTemporaryFile
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_draft_directory() -> Path:
|
|
12
|
+
return Path.cwd() / ".richforms-drafts"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_draft_path(model_name: str, *, directory: Path | None = None) -> Path:
|
|
16
|
+
root = directory or default_draft_directory()
|
|
17
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
18
|
+
stem = _safe_model_stem(model_name)
|
|
19
|
+
return root / f"{stem}-{timestamp}.yaml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def save_draft_yaml(draft: dict[str, Any], *, path: Path) -> None:
|
|
23
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
with NamedTemporaryFile(
|
|
25
|
+
mode="w",
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
dir=path.parent,
|
|
28
|
+
prefix=f".{path.stem}.",
|
|
29
|
+
suffix=".tmp",
|
|
30
|
+
delete=False,
|
|
31
|
+
) as tmp:
|
|
32
|
+
yaml.safe_dump(draft, tmp, sort_keys=False)
|
|
33
|
+
tmp_path = Path(tmp.name)
|
|
34
|
+
tmp_path.replace(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safe_model_stem(model_name: str) -> str:
|
|
38
|
+
lowered = model_name.lower()
|
|
39
|
+
safe = "".join(ch if ch.isalnum() else "-" for ch in lowered)
|
|
40
|
+
return "-".join(part for part in safe.split("-") if part) or "model"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from pydantic import AnyUrl, BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Person(BaseModel):
|
|
5
|
+
name: str = Field(..., description="Full name.", examples=["Ada Lovelace"])
|
|
6
|
+
age: int = Field(..., ge=0, description="Age in years.", examples=[36])
|
|
7
|
+
email: str = Field(
|
|
8
|
+
...,
|
|
9
|
+
pattern=r"^[^@]+@[^@]+\.[^@]+$",
|
|
10
|
+
description="Primary contact email.",
|
|
11
|
+
examples=["ada@example.com"],
|
|
12
|
+
)
|
|
13
|
+
hobbies: list[str] = Field(
|
|
14
|
+
default_factory=list,
|
|
15
|
+
description="Optional hobbies.",
|
|
16
|
+
examples=[["math", "poetry"]],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Family(BaseModel):
|
|
21
|
+
family_name: str = Field(..., description="Family surname.", examples=["Lovelace"])
|
|
22
|
+
persons: list[Person] = Field(
|
|
23
|
+
...,
|
|
24
|
+
min_length=1,
|
|
25
|
+
description="One or more family members.",
|
|
26
|
+
examples=[
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
"name": "Ada Lovelace",
|
|
30
|
+
"age": 36,
|
|
31
|
+
"email": "ada@example.com",
|
|
32
|
+
"hobbies": ["math", "poetry"],
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Metadata(BaseModel):
|
|
40
|
+
name: str = Field(
|
|
41
|
+
...,
|
|
42
|
+
title="Name",
|
|
43
|
+
description="Name of the project.",
|
|
44
|
+
examples=["richforms"],
|
|
45
|
+
)
|
|
46
|
+
repository: AnyUrl = Field(
|
|
47
|
+
...,
|
|
48
|
+
title="Repository",
|
|
49
|
+
description="Repository URL",
|
|
50
|
+
examples=["https://github.com/shinybrar/richforms"],
|
|
51
|
+
)
|
|
52
|
+
license: str = Field("MIT", title="License", description="SPDX license")
|
|
53
|
+
version: str = Field(
|
|
54
|
+
...,
|
|
55
|
+
title="Version",
|
|
56
|
+
description="Version of the project.",
|
|
57
|
+
examples=["0.1.0"],
|
|
58
|
+
)
|
richforms/exceptions.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from richforms.api import fill
|
|
9
|
+
from richforms.config import FormConfig
|
|
10
|
+
from richforms.io import load_payload_file
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T", bound=BaseModel)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def form_callback(model_type: type[T], *, config: FormConfig | None = None):
|
|
16
|
+
def _callback(ctx: Any, param: Any, value: Path | str | None) -> T:
|
|
17
|
+
del ctx, param
|
|
18
|
+
if value is None:
|
|
19
|
+
return fill(model_type, config=config)
|
|
20
|
+
path = value if isinstance(value, Path) else Path(value)
|
|
21
|
+
payload = load_payload_file(path)
|
|
22
|
+
return model_type.model_validate(payload)
|
|
23
|
+
|
|
24
|
+
return _callback
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from richforms.config import FormConfig
|
|
9
|
+
from richforms.integrations.click import form_callback as click_form_callback
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T", bound=BaseModel)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def form_callback(model_type: type[T], *, config: FormConfig | None = None):
|
|
15
|
+
callback = click_form_callback(model_type, config=config)
|
|
16
|
+
|
|
17
|
+
def _callback(value: Path | None) -> T:
|
|
18
|
+
return callback(None, None, value)
|
|
19
|
+
|
|
20
|
+
return _callback
|