Ir al contenido

SQLAlchemy

SQLAlchemy corre en el mismo proceso que la ventana (la arquitectura 1 de Arquitecturas). Hay dos demos:

  • sqlalchemy_quickstart: los modelos User y Address del ORM Quick Start de SQLAlchemy, con la misma ventana sobre una Session síncrona o sobre la extensión asyncio.
  • sqlalchemy_contacts: 20.000 filas en una tabla virtual, con búsqueda y orden en SQL.

Usuarios y sus direcciones de email del Quick Start de SQLAlchemy

Windows, macOS y Linux
cd examples/integrations/sqlalchemy_quickstart
uv run main.py # Session en hilos de trabajo
uv run main.py --async # AsyncSession con aiosqlite en el loop de Pyzed
  1. Declara los modelos igual que el Quick Start, con DeclarativeBase, Mapped y relationship. La cascada borra las direcciones de un usuario junto con el usuario:

    class Base(DeclarativeBase):
    pass
    class User(Base):
    __tablename__ = "user_account"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))
    fullname: Mapped[Optional[str]]
    addresses: Mapped[List["Address"]] = relationship(back_populates="user", cascade="all, delete-orphan")
    class Address(Base):
    __tablename__ = "address"
    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str]
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
    user: Mapped["User"] = relationship(back_populates="addresses")
  2. Crea un Engine y las tablas:

    engine = create_engine("sqlite:///quickstart.db")
    Base.metadata.create_all(engine)
  3. Abre una Session por unidad de trabajo. Cada función del backend abre su propia Session, así que se puede ejecutar sin riesgo en cualquier hilo de trabajo. selectinload carga las direcciones antes de que se cierre la sesión, para que la ventana pueda leer user.addresses después:

    def users() -> list[User]:
    stmt = select(User).options(selectinload(User.addresses)).order_by(User.name)
    with Session(engine) as session:
    return list(session.scalars(stmt))
    def add_address(user_id: int, email_address: str) -> None:
    with Session(engine) as session:
    user = session.get(User, user_id)
    user.addresses.append(Address(email_address=email_address))
    session.commit()
  4. Llámalo desde la ventana con Command.perform. La función corre en un hilo y then recibe el resultado en el hilo de la UI:

    def on_start(self):
    return Command.perform(backend.setup, lambda _: self.reload())
    def reload(self):
    return Command.perform(backend.users, self.loaded)
    def add_address(self, *_):
    user_id, email = self.selected_id, self.new_email.strip()
    return Command.perform(lambda: backend.add_address(user_id, email), lambda _: self.reload())

async_backend.py tiene las mismas funciones escritas con create_async_engine, un async_sessionmaker y el driver aiosqlite. Command.perform ve que son funciones de corrutina y las ejecuta en el loop de asyncio de Pyzed en lugar de en un hilo, así que main.py no cambia:

engine = create_async_engine("sqlite+aiosqlite:///quickstart.db")
async_session = async_sessionmaker(engine, expire_on_commit=False)
async def setup() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def users() -> list[User]:
stmt = select(User).options(selectinload(User.addresses)).order_by(User.name)
async with async_session() as session:
return list(await session.scalars(stmt))
Session AsyncSession
Dónde corre Un hilo de trabajo por llamada El loop de asyncio de Pyzed
Llamada de Pyzed Command.perform(función_síncrona, then) Command.perform(función_async, then) o devolver la corrutina
Úsala cuando La mayoría de las apps de escritorio; cualquier driver Ya usas drivers de asyncio (asyncpg, aiosqlite)
"""SQLAlchemy's ORM Quick Start in a Pyzed window: users and their email addresses.
uv run main.py # sync Session, run in worker threads (sync_backend.py)
uv run main.py --async # AsyncSession on Pyzed's asyncio loop (async_backend.py)
Both backends have the same functions and the window doesn't care which one it gets:
Command.perform runs a regular function in a thread and a coroutine on the loop.
Walkthrough: https://albakore.github.io/pyzed/integrations/sqlalchemy/
"""
from __future__ import annotations
import sys
import pyzed as pz
from pyzed import Command, ui
if "--async" in sys.argv:
import async_backend as backend
else:
import sync_backend as backend
class Quickstart(pz.App):
title = "Users · SQLAlchemy"
size = (900, 560)
def __init__(self):
self.users = []
self.selected_id: int | None = None
self.new_user = {"name": "", "fullname": ""}
self.new_email = ""
self.editing: tuple[int, str] | None = None # (address id, text being typed)
self.errors: dict[str, str] = {}
# -- database ----------------------------------------------------------------------------
def on_start(self):
return Command.perform(backend.setup, lambda _: self.reload())
def reload(self):
return Command.perform(backend.users, self.loaded)
def loaded(self, users):
self.users = users
if self.selected_id not in {u.id for u in users}:
self.selected_id = users[0].id if users else None
def run(self, work):
"""Runs a backend call, then reloads. ``work`` is a lambda, so its arguments are read now."""
return Command.perform(work, lambda _: self.reload())
# -- actions -------------------------------------------------------------------------------
def add_user(self, *_):
name, fullname = self.new_user["name"].strip(), self.new_user["fullname"].strip()
self.errors = {"name": "Required"} if not name else {"name": "30 characters at most"} if len(name) > 30 else {}
if self.errors:
return None
self.new_user = {"name": "", "fullname": ""}
return self.run(lambda: backend.add_user(name, fullname))
def add_address(self, *_):
email, self.new_email = self.new_email.strip(), ""
user_id = self.selected_id
return self.run(lambda: backend.add_address(user_id, email)) if "@" in email else None
def save_address(self, *_):
address_id, email = self.editing
self.editing = None
return self.run(lambda: backend.rename_address(address_id, email.strip()))
def delete_user(self):
user_id = self.selected_id
return self.run(lambda: backend.delete_user(user_id))
# -- view ----------------------------------------------------------------------------------
def sidebar(self, t):
rows = [ui.list_item(u.name, lambda u=u: setattr(self, "selected_id", u.id), icon="user",
end=ui.count_badge(len(u.addresses), tint="text_muted") if u.addresses else None,
selected=u.id == self.selected_id, inset=True, height=30) for u in self.users]
form = pz.column([
ui.input(self.new_user["name"], lambda v: self.new_user.__setitem__("name", v), placeholder="name",
on_submit=self.add_user),
ui.label(self.errors["name"], size="sm", color="error") if self.errors.get("name") else None,
ui.input(self.new_user["fullname"], lambda v: self.new_user.__setitem__("fullname", v),
placeholder="Full name (optional)", on_submit=self.add_user),
ui.button("Add user", self.add_user, icon="user-plus", full_width=True),
], spacing=6)
return pz.container(pz.column([ui.muted("user_account", size="xs", mono=True), *rows, pz.vspace(), form],
spacing=2), padding=8, width=260, height="fill", bg=t.panel)
def address_row(self, address):
if self.editing and self.editing[0] == address.id:
return pz.row([
ui.input(self.editing[1], lambda v: setattr(self, "editing", (address.id, v)), on_submit=self.save_address),
ui.icon_button("check", self.save_address, tooltip="Save"),
], spacing=6, align="center")
return ui.list_item(address.email_address, lambda: setattr(self, "editing", (address.id, address.email_address)),
icon="mail", end=ui.muted("click to edit", size="sm"), inset=True, height=30)
def detail(self):
user = next((u for u in self.users if u.id == self.selected_id), None)
if user is None:
return ui.empty_state("No users", "Add one on the left.", icon="users")
return pz.column([
pz.row([ui.heading(user.fullname or user.name, level=2), pz.hspace(),
ui.button("Delete user", self.delete_user, icon="trash-2", variant="ghost", tint="error")],
align="center"),
ui.muted(repr(user), mono=True, size="sm"),
ui.label("Addresses", weight="semibold"),
*[self.address_row(a) for a in user.addresses],
pz.row([ui.input(self.new_email, lambda v: setattr(self, "new_email", v), placeholder="new@sqlalchemy.org",
icon="mail-plus", on_submit=self.add_address),
ui.button("Add", self.add_address)], spacing=6),
], spacing=10, padding=24)
def view(self):
t = self.resolved_theme()
mode = "AsyncSession · aiosqlite" if backend.__name__ == "async_backend" else "Session · worker threads"
return pz.column([
pz.row([self.sidebar(t), pz.container(self.detail(), width="fill", height="fill", bg=t.editor)]),
ui.status_bar([ui.status_item(mode, icon="database", icon_color="success")]),
])
if __name__ == "__main__":
pz.run(Quickstart)

Un gestor de contactos sobre SQLAlchemy 2.0 y SQLite. La tabla tiene 20.000 filas y se desplaza con fluidez porque ui.table solo construye las filas visibles; la búsqueda y el orden se hacen en SQL, en un hilo de trabajo, así que la ventana nunca espera a la base de datos.

Una tabla de contactos con 20.000 filas, estadísticas y un panel de edición

Windows, macOS y Linux
cd examples/integrations/sqlalchemy_contacts
uv run main.py

La demo guarda el código de la base de datos en db.py, sin nada de Pyzed, y la ventana en main.py. Vale la pena copiar esa separación: el mismo db.py podría servir a una app de FastAPI o a una CLI.

  1. Los modelos son SQLAlchemy 2.0 normal.

    class Contact(Base):
    __tablename__ = "contacts"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(80), index=True)
    email: Mapped[str] = mapped_column(String(120), unique=True)
    company: Mapped[str] = mapped_column(String(80), default="")
    city: Mapped[str] = mapped_column(String(60), default="")
  2. Cada consulta es una función con su propia sesión. Las sesiones de SQLAlchemy no se comparten entre hilos, así que cada función abre una y la cierra. Los objetos cargados conservan sus valores después de cerrar la sesión, que es todo lo que necesita la vista:

    def search(text, sort, descending):
    column = COLUMNS[sort]
    query = select(Contact).order_by(column.desc() if descending else column, Contact.id)
    if text:
    pattern = f"%{text}%"
    query = query.where(or_(Contact.name.ilike(pattern), Contact.email.ilike(pattern),
    Contact.company.ilike(pattern)))
    with Session(engine) as session:
    return list(session.scalars(query))
  3. Ejecútala con Command.perform. La función corre en un hilo y then recibe el resultado en el hilo de la UI. Devolver una lista de comandos los ejecuta juntos:

    def reload(self):
    query, sort, descending = self.query, self.sort, self.descending
    return [
    Command.perform(lambda: db.search(query, sort, descending), lambda rows: self.loaded(query, rows)),
    Command.perform(db.stats, lambda stats: setattr(self, "stats", stats)),
    ]
    def loaded(self, query, rows):
    if query == self.query: # a newer search is on its way: keep waiting for it
    self.rows, self.loading = rows, False

    Copia primero en variables locales el estado que necesitas (query, sort, ...): la lambda se ejecuta más tarde, en otro hilo, y no debería leer self mientras el usuario sigue escribiendo.

  4. Pasa todas las filas a ui.table. Está virtualizada, así que 20.000 filas cuestan lo mismo que 20. Al pulsar una cabecera se llama a on_sort, que aquí vuelve a ejecutar la consulta con un nuevo ORDER BY:

    ui.table(columns, self.rows, sort_by=self.sort, descending=self.descending, on_sort=self.sort_by,
    on_row_press=self.open, selected=self.editing, striped=True, height="fill")
  5. Convierte los errores de la base de datos en errores de campo. save devuelve el contacto o {campo: mensaje}. Una restricción de unicidad se convierte en un mensaje bajo el campo Email:

    try:
    session.commit()
    except IntegrityError:
    return {"email": "Another contact already uses this email"}
    ui.field("Email", ui.input(self.draft["email"], ...), error=self.errors.get("email"))
"""A contacts manager on SQLAlchemy 2.0: 20,000 rows in a virtual table, searched and sorted in SQL.
uv run main.py
The database (contacts.db) is created next to this file on the first run.
Walkthrough: https://albakore.github.io/pyzed/integrations/sqlalchemy/
"""
from __future__ import annotations
import pyzed as pz
from pyzed import Command, ui
import db
FIELDS = [("name", "Name", "Ada Lovelace"), ("email", "Email", "ada@example.com"),
("company", "Company", "Analytical Engines"), ("city", "City", "London")]
class Contacts(pz.App):
title = "Contacts · SQLAlchemy"
size = (1180, 720)
def __init__(self):
self.rows: list[db.Contact] = []
self.stats = {"total": 0, "companies": 0, "cities": []}
self.query = ""
self.sort, self.descending = "Name", False
self.loading = True
self.editing = None # a Contact, "new" or None
self.draft: dict[str, str] = {}
self.errors: dict[str, str] = {}
# -- database work runs in a thread with Command.perform ------------------------------
def on_start(self):
return Command.perform(db.setup, lambda _: self.reload())
def reload(self):
self.loading = True
query, sort, descending = self.query, self.sort, self.descending
return [
Command.perform(lambda: db.search(query, sort, descending), lambda rows: self.loaded(query, rows)),
Command.perform(db.stats, lambda stats: setattr(self, "stats", stats)),
]
def loaded(self, query: str, rows: list[db.Contact]):
if query == self.query: # a newer search is on its way: keep waiting for it
self.rows, self.loading = rows, False
def search(self, text: str):
self.query = text
return self.reload()
def sort_by(self, title: str):
self.descending = not self.descending if title == self.sort else False
self.sort = title
return self.reload()
# -- the edit panel ------------------------------------------------------------------
def open(self, contact):
self.editing, self.errors = contact, {}
self.draft = {key: "" if contact == "new" else getattr(contact, key) for key, _, _ in FIELDS}
def save(self):
contact_id = None if self.editing == "new" else self.editing.id
values = dict(self.draft)
return Command.perform(lambda: db.save(contact_id, values), self.saved)
def saved(self, result):
if isinstance(result, dict):
self.errors = result
return None
self.editing = None
return self.reload()
def delete(self):
contact_id = self.editing.id
self.editing = None
return Command.perform(lambda: db.delete(contact_id), lambda _: self.reload())
def on_key(self, combo):
if combo == "escape":
self.editing = None
elif combo == "ctrl-n":
self.open("new")
# -- view ----------------------------------------------------------------------------
def header(self):
cities = self.stats["cities"]
return pz.row([
ui.stat("Contacts", f"{self.stats['total']:,}"),
ui.stat("Companies", self.stats["companies"]),
ui.card(pz.column([ui.muted("Top cities", size="sm"),
ui.bar_chart([count for _, count in cities], labels=[c.split()[0] for c, _ in cities],
width=360, height=90)], spacing=6)),
], spacing=12)
def panel(self, t):
if self.editing is None:
return None
rows = [ui.field(title, ui.input(self.draft[key], lambda v, k=key: self.draft.__setitem__(k, v),
placeholder=hint, width=220),
error=self.errors.get(key))
for key, title, hint in FIELDS]
new = self.editing == "new"
return pz.container(
pz.column([
pz.row([ui.heading("New contact" if new else "Edit contact", level=3), pz.hspace(),
ui.icon_button("x", lambda: setattr(self, "editing", None), tooltip="Close (Esc)")],
align="center"),
*rows,
pz.row([None if new else ui.button("Delete", self.delete, icon="trash-2", variant="ghost", tint="error"),
pz.hspace(), ui.button("Save", self.save, icon="check", variant="solid")], spacing=8),
], spacing=14),
padding=16, width=420, height="fill", bg=t.panel,
)
def view(self):
t = self.resolved_theme()
columns = [
ui.Column("Name", "name", width="fill:3"),
ui.Column("Email", "email", width="fill:3"),
ui.Column("Company", "company", width="fill:2"),
ui.Column("City", "city", width="fill:2"),
]
toolbar = pz.row([
ui.search_input(self.query, self.search, placeholder="Search by name, email or company…", width=360),
ui.spinner() if self.loading else ui.muted(f"{len(self.rows):,} found", size="sm"),
pz.hspace(),
ui.button("New contact", lambda: self.open("new"), icon="user-plus", keys="ctrl-n"),
], spacing=10, align="center")
# Rows come sorted from SQL; sort_by only draws the arrow (re-sorting a sorted list is cheap).
table = ui.table(columns, self.rows, sort_by=self.sort, descending=self.descending, on_sort=self.sort_by,
on_row_press=self.open, selected=self.editing, striped=True, height="fill")
main = pz.column([self.header(), toolbar, table], spacing=14, padding=16)
return pz.container(pz.row([main, self.panel(t)]), width="fill", height="fill", bg=t.editor)
if __name__ == "__main__":
pz.run(Contacts)