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 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,3 @@
1
+ from richforms.example.model import Family, Person
2
+
3
+ __all__ = ["Family", "Person"]
@@ -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
+ )
@@ -0,0 +1,6 @@
1
+ class RichformsError(Exception):
2
+ """Base error for richforms."""
3
+
4
+
5
+ class SerializationError(RichformsError):
6
+ """Raised when serialization fails."""
@@ -0,0 +1,4 @@
1
+ from richforms.integrations.click import form_callback as click_form_callback
2
+ from richforms.integrations.typer import form_callback as typer_form_callback
3
+
4
+ __all__ = ["click_form_callback", "typer_form_callback"]
@@ -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