Système de formulaires Skrift

Système de formulaires avec validation Pydantic, protection CSRF, rendu de templates et intégration de hooks.

Spar Skills Guide Bot
DeveloppementIntermédiaire
1024/07/2026
Claude CodeCursorWindsurfCopilotCodex
#skrift#forms#pydantic#csrf#template-rendering

Recommandé pour


name: skrift-forms description: "Skrift form system — Pydantic-backed forms with CSRF protection, template rendering, and hook integration."

Skrift Form System

Skrift forms combine Pydantic validation with CSRF protection, template rendering, and hook integration.

Architecture

FormModel/BaseModel → Form(model, request) → validate() → hooks → data
      │                      │                    │           │
      │                      │                    │           ├─ form_{name}_validated
      │                      │                    │           └─ form_validated
      │                      │                    │
      │                      │                    ├─ CSRF verify (hmac.compare_digest)
      │                      │                    ├─ Token rotation (single-use)
      │                      │                    ├─ Checkbox injection (bool fields)
      │                      │                    └─ Pydantic validation
      │                      │
      │                      ├─ render() → Template("form", name).try_render()
      │                      ├─ csrf_field() → hidden input
      │                      ├─ fields → dict[str, BoundField]
      │                      └─ errors → dict[str, str]
      │
      └─ Registered in _form_registry (by form_name)

Defining Forms

FormModel (auto-registers)

from skrift.forms import FormModel
from pydantic import Field, EmailStr

class ContactForm(FormModel, form_name="contact"):
    name: str
    email: EmailStr
    message: str = Field(json_schema_extra={"widget": "textarea"})

@form() Decorator (registers a plain BaseModel)

from pydantic import BaseModel
from skrift.forms import form

@form("newsletter", action="/subscribe", method="post")
class NewsletterForm(BaseModel):
    email: str

Field Customization via json_schema_extra

| Key | Values | Purpose | |-----|--------|---------| | label | str | Custom label text | | widget | "textarea", "select", "checkbox" | Widget type | | input_type | "email", "password", etc. | HTML input type | | help_text | str | Help text below field | | choices | list[tuple[str, str]] | Options for select widget | | attrs | dict[str, str] | Extra HTML attributes |

Full example:

class ContactForm(FormModel, form_name="contact"):
    name: str = Field(json_schema_extra={
        "label": "Your Name",
        "attrs": {"placeholder": "Jane Doe"},
    })
    email: EmailStr = Field(json_schema_extra={
        "label": "Email Address",
        "input_type": "email",
        "help_text": "We'll never share your email.",
    })
    subject: str = Field(json_schema_extra={
        "widget": "select",
        "choices": [
            ("general", "General Inquiry"),
            ("support", "Technical Support"),
        ],
    })
    message: str = Field(json_schema_extra={
        "widget": "textarea",
        "attrs": {"rows": "6"},
    })
    subscribe: bool = Field(default=False, json_schema_extra={
        "label": "Subscribe to newsletter",
    })

Controller GET/POST Pattern

from litestar import Controller, get, post, Request
from litestar.response import Template as TemplateResponse, Redirect
from skrift.forms import Form

class ContactController(Controller):
    path = "/contact"

    @get("/")
    async def show(self, request: Request) -> TemplateResponse:
        form = Form(ContactForm, request)
        return TemplateResponse("contact.html", context={"form": form})

    @post("/")
    async def submit(self, request: Request) -> TemplateResponse | Redirect:
        form = Form(ContactForm, request)
        if await form.validate():
            # form.data is a validated ContactForm instance
            await process_contact(form.data)
            return Redirect("/contact?thanks=1")
        return TemplateResponse("contact.html", context={"form": form})

Template Rendering

Automatic

{{ form.render() }}
{{ form.render(submit_label="Send") }}

Manual

<form method="{{ form.method }}">
    {{ form.csrf_field() }}
    {% for field in form %}
        {{ field.label_tag() }}
        {{ field.widget() }}
    {% endfor %}
    <button type="submit">Send</button>
</form>

Custom Form Template

Template hierarchy: form-{name}.htmlform.html → programmatic fallback.

{# templates/form-contact.html #}
<form method="{{ form.method }}" class="contact-form">
    {{ form.csrf_field() }}

    {% if form.form_error %}
        <div class="alert">{{ form.form_error }}</div>
    {% endif %}

    <div class="row">
        <div class="col">
            {{ form['name'].label_tag() }}
            {{ form['name'].widget(class_="form-input") }}
        </div>
        <div class="col">
            {{ form['email'].label_tag() }}
            {{ form['email'].widget(class_="form-input") }}
        </div>
    </div>

    {{ form['message'].label_tag() }}
    {{ form['message'].widget(class_="form-input", rows="8") }}

    <button type="submit">{{ submit_label }}</button>
</form>

CSRF Flow

  1. Form.__init__() — Creates secrets.token_urlsafe(32) in session if absent (key: _csrf_token)
  2. csrf_field() — Renders <input type="hidden" name="_csrf" value="{token}">
  3. validate() — Checks submitted _csrf against session token via hmac.compare_digest()
  4. Token rotation — New token generated after successful check (single-use)

Registration Mechanism

Forms are registered in a global _form_registry dict (in model.py):

  • FormModel.__init_subclass__() auto-registers on class creation
  • @form() decorator registers explicitly
  • get_form_model(name) retrieves by name, raises LookupError if missing

Hook Integration

Two filter hooks fire after successful validation:

# Form-specific: e.g. "form_contact_validated"
self.data = await hooks.apply_filters(f"form_{self.name}_validated", self.data)

# Global: "form_validated"
self.data = await hooks.apply_filters("form_validated", self.data, self.name)

Example hook:

from skrift.hooks import filter

@filter("form_contact_validated")
async def sanitize_contact(data):
    data.message = data.message.strip()
    return data

@filter("form_validated")
async def log_all_forms(data, name):
    print(f"Form '{name}' submitted")
    return data

Jinja2 Global

The Form class is registered as a Jinja2 global, making it accessible in templates without passing it through context.

Key Files

| File | Purpose | |------|---------| | skrift/forms/core.py | Form — CSRF, validation, rendering | | skrift/forms/model.py | FormModel base class, _form_registry, get_form_model() | | skrift/forms/fields.py | BoundField — field bound to form instance | | skrift/forms/decorators.py | @form() decorator | | skrift/templates/form.html | Default form template |

Skills similaires