Skip to content

Commit f32248c

Browse files
committed
Ship a working CLI and real documentation
The README now shows what AirForm does (features list, quick start with Air, standalone usage) instead of a TODO placeholder. The `airform` CLI previews rendered HTML for any importable Pydantic model with syntax highlighting. docs/usage.md covers validation, rendering, from_request, Depends, custom widgets, and CLI usage. Key design decisions: - CLI uses importlib to load the model, so it works with any project without AirForm needing to know the project structure - rich and typer are runtime dependencies (needed for the CLI entry point, not just dev) - Placeholder utils.py removed
1 parent 2383763 commit f32248c

6 files changed

Lines changed: 276 additions & 31 deletions

File tree

README.md

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
![PyPI version](https://img.shields.io/pypi/v/AirForm.svg)
44

5-
Pydantic-native form validation and rendering. Define a model, get a validated, rendered HTML form. Works with or without Air web framework.
5+
Pydantic-native form validation and rendering. Define a model, get a validated, rendered HTML form. Built for [Air](https://github.com/feldroy/air), also works standalone with FastAPI, Starlette, Litestar, or any ASGI framework.
66

77
* GitHub: https://github.com/feldroy/AirForm/
88
* PyPI package: https://pypi.org/project/AirForm/
@@ -11,7 +11,62 @@ Pydantic-native form validation and rendering. Define a model, get a validated,
1111

1212
## Features
1313

14-
* TODO
14+
* Type-safe validated data via `AirForm[MyModel]` generic parameter
15+
* Reads the full [AirField](https://github.com/feldroy/AirField) metadata vocabulary: Widget, Label, Placeholder, HelpText, Choices, Autofocus, PrimaryKey, Hidden, ReadOnly
16+
* Auto-skips PrimaryKey and Hidden("form") fields in rendered output
17+
* HTML5 validation attributes from Pydantic constraints (minlength, maxlength, required)
18+
* Accessible by default: aria-invalid, aria-describedby, role="alert" on errors
19+
* Textarea, select, and checkbox rendering from type annotations and metadata
20+
* Swappable widget for custom renderers
21+
* `from_request()` for async ASGI request handling (works with FastAPI Depends)
22+
23+
## Quick start
24+
25+
### With Air
26+
27+
```python
28+
from air import AirForm, AirModel, AirField
29+
import air
30+
31+
app = air.Air()
32+
33+
class Contact(AirModel):
34+
name: str
35+
email: str = AirField(type="email", label="Email Address")
36+
37+
class ContactForm(AirForm[Contact]):
38+
pass
39+
40+
@app.post("/contact")
41+
async def submit(request: air.Request):
42+
form = await ContactForm.from_request(request)
43+
if form.is_valid:
44+
return air.Html(air.H1(f"Thanks, {form.data.name}!"))
45+
return air.Html(air.Raw(form.render()))
46+
```
47+
48+
### Standalone (any ASGI framework)
49+
50+
```python
51+
from pydantic import BaseModel
52+
from airform import AirForm
53+
54+
class ContactModel(BaseModel):
55+
name: str
56+
email: str
57+
58+
class ContactForm(AirForm[ContactModel]):
59+
pass
60+
61+
# Validate
62+
form = ContactForm()
63+
form.validate({"name": "Audrey", "email": "audreyfeldroy@example.com"})
64+
if form.is_valid:
65+
print(form.data.name) # type-safe: editor knows this is str
66+
67+
# Render
68+
html = ContactForm().render()
69+
```
1570

1671
## Documentation
1772

@@ -25,32 +80,23 @@ API documentation is auto-generated from docstrings using [mkdocstrings](https:/
2580

2681
Docs deploy automatically on push to `main` via GitHub Actions. To enable this, go to your repo's Settings > Pages and set the source to **GitHub Actions**.
2782

28-
## Development
29-
30-
To set up for local development:
83+
## Installation
3184

3285
```bash
33-
# Clone your fork
34-
git clone git@github.com:your_username/AirForm.git
35-
cd AirForm
36-
37-
# Install in editable mode with live updates
38-
uv tool install --editable .
86+
uv add AirForm
3987
```
4088

41-
This installs the CLI globally but with live updates - any changes you make to the source code are immediately available when you run `airform`.
89+
## CLI
4290

43-
Run tests:
91+
Preview rendered form HTML from any Pydantic model:
4492

4593
```bash
46-
uv run pytest
94+
airform myapp.models:ContactModel
4795
```
4896

49-
Run quality checks (format, lint, type check, test):
97+
## Development
5098

51-
```bash
52-
just qa
53-
```
99+
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
54100

55101
## Author
56102

docs/usage.md

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,81 @@
11
# Usage
22

3-
To use AirForm in a project:
3+
## Define a model and form
44

55
```python
6-
import airform
6+
from pydantic import BaseModel
7+
from airfield import AirField
8+
from airform import AirForm
9+
10+
class ContactModel(BaseModel):
11+
name: str
12+
email: str = AirField(type="email", label="Email Address")
13+
message: str = AirField(widget="textarea", placeholder="Your message...")
14+
15+
class ContactForm(AirForm[ContactModel]):
16+
pass
17+
```
18+
19+
## Validate
20+
21+
```python
22+
form = ContactForm()
23+
form.validate({"name": "Audrey", "email": "audreyfeldroy@example.com", "message": "Hello!"})
24+
25+
if form.is_valid:
26+
print(form.data.name) # "Audrey" — typed as str
27+
print(form.data.email) # autocomplete works
28+
```
29+
30+
## Render
31+
32+
```python
33+
html = ContactForm().render()
34+
```
35+
36+
Produces structured HTML with labels, inputs, accessibility attributes, and error messages. PrimaryKey and Hidden("form") fields are auto-skipped.
37+
38+
## Validate from a request
39+
40+
Works with any ASGI framework (Starlette, FastAPI, Litestar):
41+
42+
```python
43+
@app.post("/contact")
44+
async def submit(request):
45+
form = await ContactForm.from_request(request)
46+
if form.is_valid:
47+
send_email(form.data.name, form.data.email, form.data.message)
48+
```
49+
50+
With FastAPI dependency injection:
51+
52+
```python
53+
from typing import Annotated
54+
from fastapi import Depends
55+
56+
@app.post("/contact")
57+
async def submit(form: Annotated[ContactForm, Depends(ContactForm.from_request)]):
58+
if form.is_valid:
59+
send_email(form.data.name, form.data.email, form.data.message)
60+
```
61+
62+
## Custom widget
63+
64+
Swap the renderer by setting `widget` on your form subclass:
65+
66+
```python
67+
def my_renderer(*, model, data=None, errors=None, includes=None):
68+
# Return an HTML string
69+
...
70+
71+
class ContactForm(AirForm[ContactModel]):
72+
widget = staticmethod(my_renderer)
73+
```
74+
75+
## CLI preview
76+
77+
Preview rendered HTML for any importable model:
78+
79+
```bash
80+
airform myapp.models:ContactModel
781
```

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ dependencies = [
2323
"pydantic>=2.0",
2424
"AirField>=0.4.0",
2525
"annotated-types",
26+
"rich",
2627
"starlette",
28+
"typer",
2729
]
2830
requires-python = ">= 3.12"
2931

@@ -54,8 +56,8 @@ changelog = "https://github.com/feldroy/AirForm/releases"
5456
documentation = "https://feldroy.github.io/AirForm/"
5557
homepage = "https://github.com/feldroy/AirForm"
5658

57-
# [project.scripts]
58-
# airform = "airform.cli:app"
59+
[project.scripts]
60+
airform = "airform.cli:app"
5961

6062
[tool.ty]
6163
# All rules are enabled as "error" by default; no need to specify unless overriding.

src/airform/cli.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
1-
"""Console script for airform."""
1+
"""Console script for airform.
2+
3+
Preview rendered form HTML from a Pydantic model:
4+
5+
airform preview myapp:ContactModel
6+
"""
7+
8+
import importlib
29

310
import typer
411
from rich.console import Console
12+
from rich.syntax import Syntax
513

6-
from airform import utils
14+
from airform import default_form_widget
715

816
app = typer.Typer()
917
console = Console()
1018

1119

1220
@app.command()
13-
def main() -> None:
14-
"""Console script for airform."""
15-
console.print("Replace this message by putting your code into airform.cli.main")
16-
console.print("See Typer documentation at https://typer.tiangolo.com/")
17-
utils.do_something_useful()
21+
def preview(model_path: str) -> None:
22+
"""Render a Pydantic model as form HTML and print it.
23+
24+
MODEL_PATH is module:ClassName, e.g. myapp.models:ContactModel
25+
"""
26+
module_name, _, class_name = model_path.rpartition(":")
27+
if not module_name or not class_name:
28+
console.print(f"[red]Expected module:ClassName, got {model_path!r}[/red]")
29+
raise typer.Exit(1)
30+
31+
module = importlib.import_module(module_name)
32+
model = getattr(module, class_name)
33+
html = default_form_widget(model=model)
34+
console.print(Syntax(html, "html", theme="monokai"))
1835

1936

2037
if __name__ == "__main__":

src/airform/utils.py

Lines changed: 0 additions & 2 deletions
This file was deleted.

0 commit comments

Comments
 (0)