# Pydantic

> Valida formularios de Pyzed con modelos de Pydantic, muestra los errores en cada campo y lee o guarda JSON con model_validate_json y model_dump_json.

Source: https://albakore.github.io/pyzed/es/integrations/pydantic/

[Pydantic](https://docs.pydantic.dev/) comprueba datos contra anotaciones de tipo. En una app de
escritorio hace dos trabajos: valida lo que el usuario escribió en un formulario y convierte el
JSON de archivos y APIs. Corre en el mismo proceso que la ventana, sin necesidad de hilos ni de
loop, porque validar es rápido.

(Image: Un formulario de registro con errores de Pydantic y las cuentas guardadas como JSON)

1. **Escribe las reglas como un modelo.** Los [campos](https://docs.pydantic.dev/latest/concepts/fields/)
   llevan las restricciones, tipos especiales como `EmailStr` (de `pydantic[email]`) y `SecretStr`
   hacen el resto, y los [validadores](https://docs.pydantic.dev/latest/concepts/validators/)
   añaden comprobaciones propias. Un validador de modelo `after` compara dos campos:

```python
class SignUp(BaseModel):
    username: str = Field(min_length=3, max_length=20, pattern=r"^[a-z0-9_]+$")
    email: EmailStr
    birthday: date
    plan: Literal["free", "pro", "team"] = "free"
    password: SecretStr
    password_repeat: SecretStr
    newsletter: bool = False

    @field_validator("username", mode="before")
    @classmethod
    def lowercase(cls, value: str) -> str:
        return value.strip().lower()

    @model_validator(mode="after")
    def passwords_match(self) -> "SignUp":
        if self.password.get_secret_value() != self.password_repeat.get_secret_value():
            raise ValueError("The passwords don't match")
        return self
```

2. **Guarda el formulario como valores simples y valida al enviar.** Los inputs dan texto;
   Pydantic convierte `"1990-05-02"` en un `date` e informa de todos los problemas a la vez:

```python
def submit(self, *_):
    try:
        signup = SignUp.model_validate(self.draft)
    except ValidationError as error:
        self.errors = pz.validation_errors(error)
        return
    ...
```

3. **Muestra cada error bajo su campo.** `pz.validation_errors` devuelve `{campo: mensaje}`; los
   errores de un validador de modelo usan la clave `""`:

```python
ui.field("Email", ui.input(d["email"], self.edit("email")), error=self.errors.get("email"))
ui.callout(self.errors[""], tint="error") if self.errors.get("") else None
```

4. **Guarda y carga JSON.** [Serialización](https://docs.pydantic.dev/latest/concepts/serialization/)
   con `model_dump` y `model_dump_json`, y un `TypeAdapter` para una lista entera:

```python
account = Account.model_validate(signup.model_dump(exclude={"password", "password_repeat"}))

Accounts = TypeAdapter(list[Account])
FILE.write_bytes(Accounts.dump_json(self.accounts, indent=2))
accounts = Accounts.validate_json(FILE.read_bytes())
```

## Leer respuestas de APIs

Los mismos modelos convierten el JSON que llega por la red. Declara solo los campos que usas;
Pydantic ignora el resto, convierte las fechas y falla con un error claro si la API cambia de
forma. Del [explorador de PyPI](https://albakore.github.io/pyzed/es/integrations/httpx/#peticiones-concurrentes-el-explorador-de-pypi):

```python
class Info(BaseModel):
    name: str
    version: str
    summary: str | None = None

class File(BaseModel):
    upload_time_iso_8601: datetime

class Package(BaseModel):
    info: Info
    releases: dict[str, list[File]]

package = Package.model_validate_json(response.content)
```

**Tip:**

Los modelos de Pydantic son lo que FastAPI usa para los cuerpos de las peticiones y la base sobre
la que se construye SQLModel. Un modelo escrito para el formulario de escritorio puede convertirse
más adelante en el cuerpo de un endpoint de una API; mira
[FastAPI](https://albakore.github.io/pyzed/es/integrations/fastapi/) y [SQLModel](https://albakore.github.io/pyzed/es/integrations/sqlmodel/).

## Código completo

**main.py:**
```py
"""A sign-up form whose rules live in a Pydantic model; accounts are saved to accounts.json.

    uv run main.py

The window keeps what the user types as plain strings and hands them to
``SignUp.model_validate``: Pydantic converts them (a date, an email, a bool), checks every rule
and reports the errors per field, which ``pz.validation_errors`` turns into ``{field: message}``.
Walkthrough: https://albakore.github.io/pyzed/integrations/pydantic/
"""

from __future__ import annotations

from pathlib import Path

from pydantic import ValidationError

import pyzed as pz
from pyzed import ui

from models import Account, Accounts, SignUp

FILE = Path(__file__).with_name("accounts.json")
EMPTY = {"username": "", "email": "", "birthday": "", "plan": "free", "password": "", "password_repeat": "",
         "newsletter": False}

def load() -> list[Account]:
    return Accounts.validate_json(FILE.read_bytes()) if FILE.exists() else []

class SignUpForm(pz.App):
    title = "Sign up · Pydantic"
    size = (860, 600)

    def __init__(self):
        self.draft = dict(EMPTY)
        self.errors: dict[str, str] = {}
        self.accounts = load()
        self.preview = ""  # the JSON Pydantic produced for the last account

    def edit(self, key):
        return lambda value: self.draft.__setitem__(key, value)

    def submit(self, *_):
        try:
            signup = SignUp.model_validate(self.draft)
        except ValidationError as error:
            self.errors = pz.validation_errors(error)  # the model validator's error has the key ""
            return
        account = Account.model_validate(signup.model_dump(exclude={"password", "password_repeat"}))
        if any(a.username == account.username for a in self.accounts):
            self.errors = {"username": "That username is taken"}
            return
        self.accounts.append(account)
        FILE.write_bytes(Accounts.dump_json(self.accounts, indent=2))
        self.preview = account.model_dump_json(indent=2)
        self.draft, self.errors = dict(EMPTY), {}

    def form(self):
        e, d = self.errors.get, self.draft

        def text(key, title, placeholder="", **kwargs):
            return ui.field(title, ui.input(d[key], self.edit(key), placeholder=placeholder, on_submit=self.submit,
                                            **kwargs), error=e(key))

        return pz.column([
            ui.heading("Create an account", level=2),
            text("username", "Username", "lowercase letters, digits and _"),
            text("email", "Email", "you@example.com"),
            text("birthday", "Birthday", "YYYY-MM-DD"),
            ui.field("Plan", ui.segmented(["free", "pro", "team"], d["plan"], self.edit("plan"))),
            text("password", "Password", "8 characters or more", secure=True),
            text("password_repeat", "Repeat the password", secure=True),
            ui.checkbox(d["newsletter"], self.edit("newsletter"), label="Send me the newsletter"),
            ui.callout(e(""), tint="error") if e("") else None,
            ui.button("Sign up", self.submit, icon="user-plus", variant="solid", full_width=True),
        ], spacing=12, padding=24, width=420)

    def side(self, t):
        rows = [ui.list_item(a.username, None, icon="user", end=ui.badge(a.plan), inset=True, height=30)
                for a in reversed(self.accounts)]
        return pz.container(pz.column([
            ui.muted("accounts.json", size="xs", mono=True),
            *rows,
            ui.card(ui.label(self.preview, mono=True, size="sm"), title="model_dump_json()") if self.preview else None,
        ], spacing=6), padding=16, width="fill", height="fill", bg=t.panel)

    def view(self):
        t = self.resolved_theme()
        return pz.row([pz.container(pz.scrollable(self.form()), height="fill", bg=t.editor), self.side(t)])

if __name__ == "__main__":
    pz.run(SignUpForm)
```

**models.py:**
```py
"""The form's rules, written as Pydantic models (https://docs.pydantic.dev/latest/concepts/models/)."""

from __future__ import annotations

from datetime import date
from typing import Literal

from pydantic import BaseModel, EmailStr, Field, SecretStr, TypeAdapter, field_validator, model_validator

class SignUp(BaseModel):
    username: str = Field(min_length=3, max_length=20, pattern=r"^[a-z0-9_]+$")
    email: EmailStr
    birthday: date
    plan: Literal["free", "pro", "team"] = "free"
    password: SecretStr  # repr() and str() show ********
    password_repeat: SecretStr
    newsletter: bool = False

    @field_validator("username", mode="before")
    @classmethod
    def lowercase(cls, value: str) -> str:
        return value.strip().lower()

    @field_validator("birthday")
    @classmethod
    def old_enough(cls, value: date) -> date:
        today = date.today()
        age = today.year - value.year - ((today.month, today.day) < (value.month, value.day))
        if age < 13:
            raise ValueError("You must be at least 13 years old")
        return value

    @field_validator("password")
    @classmethod
    def long_enough(cls, value: SecretStr) -> SecretStr:
        if len(value.get_secret_value()) < 8:
            raise ValueError("Use 8 characters or more")
        return value

    @model_validator(mode="after")
    def passwords_match(self) -> SignUp:
        if self.password.get_secret_value() != self.password_repeat.get_secret_value():
            raise ValueError("The passwords don't match")
        return self

class Account(BaseModel):
    """What gets saved: the sign-up without the passwords."""

    username: str
    email: EmailStr
    birthday: date
    plan: Literal["free", "pro", "team"]
    newsletter: bool

Accounts = TypeAdapter(list[Account])  # validates and serializes a whole list at once
```
