Cuaderno/Proyectos/3 · Inventario en SQLite
Proyecto 3 de 10 · Bloque 2

Inventario de reactivos en SQLite

El sistema que vas a hacer crecer hasta el proyecto 10. Hoy: un CLI sobre una base de datos de verdad, con esquema, claves, transacciones y migraciones. Lo que Excel nunca te dio: garantías.

concepto · persistencia, esquema y claves4 sesionessqlmodel · sqlite · alembic · typer · docker compose
ConstruyesRepo inventario-lab, CLI inv sobre SQLite
AprendesEsquema, claves foráneas, transacciones, migraciones, índices
CostoNinguno. SQLite es un archivo en tu disco.
Terminas conLa base sobre la que se montan la API (P4), el vigía (P7) y el MCP (P10)

Objetivos

Al terminar este proyecto vas a poder:

  • Diseñar un esquema en papel: qué tablas, qué claves, qué relaciones, y defenderlo.
  • Escribir las reglas del inventario como funciones que reciben una sesión y no saben nada de la terminal.
  • Explicar qué es una transacción con el caso "salida con stock insuficiente no deja la base a medias".
  • Versionar cambios de esquema con migraciones, igual que versionas código con git.
  • Mostrar el SQL que el ORM genera por ti, y explicar por qué un índice hace una búsqueda miles de veces más rápida.
  • Demostrar (con un experimento propio) qué es una inyección SQL y por qué las consultas van parametrizadas.
Este proyecto funda el sistema

Todo lo que escribas aquí (modelos, servicios, errores) se reutiliza sin cambios en los proyectos 4 al 10: la API le pone una puerta HTTP, el vigía lo consulta cada mañana, el MCP lo expone a un agente. Por eso la separación en capas no es estética: es lo que permite que el sistema crezca sin reescribirse.

Temas que vas a usar

Léelos antes o consúltalos cuando aparezcan. Los primeros son de El libro de Python; los últimos, de esta guía.

Antes de empezar

Necesitas el P2 terminado (sabes validar datos y separar capas) y las herramientas del P0 funcionando. Verifica:

uv --version
docker compose version
sqlite3 --version    # viene con macOS; en Linux: apt install sqlite3

sqlite3 es opcional pero útil: te deja abrir el archivo de la base y mirar adentro sin Python.

La pregunta antes de escribir código

¿Por qué no un CSV o un Excel? Porque el inventario lo van a tocar varias piezas a la vez (el CLI hoy, la API y el vigía después), y porque "sacar 50 µL del lote TAQ-2401" son dos escrituras que tienen que pasar juntas o no pasar. Un archivo plano no puede prometer ninguna de las dos cosas. Una base de datos, sí: eso es exactamente lo que es.

Construcción paso a paso

Como siempre: escribe los archivos tú misma, corre cada comando, compara con "deberías ver". El proyecto se llama inventario-lab y el paquete inventario.

Paso 1 El esquema, en papel

Antes de cualquier código, decide qué existe y cómo se relaciona. En papel (literal). El resultado para nuestro inventario:

┌────────────┐        ┌────────────┐
│ Ubicacion  │        │  Reactivo  │
│────────────│        │────────────│
│ id (PK)    │        │ id (PK)    │
│ nombre ⚷   │        │ nombre     │
└─────┬──────┘        │ cas        │
      │               │ unidad     │
      │ 1             └─────┬──────┘
      │                     │ 1
      │ *                   │ *
┌─────┴─────────────────────┴──────┐
│               Lote               │
│──────────────────────────────────│
│ id (PK)                          │
│ reactivo_id (FK → reactivo.id)   │
│ ubicacion_id (FK → ubicacion.id) │
│ codigo ⚷   p.ej. "TAQ-2401"      │
│ cantidad                         │
│ vencimiento                      │
└────────────────┬─────────────────┘
                 │ 1
                 │ *
        ┌────────┴─────────┐
        │    Movimiento    │
        │──────────────────│
        │ id (PK)          │
        │ lote_id (FK)     │
        │ cantidad (±)     │
        │ fecha (UTC)      │
        │ nota             │
        └──────────────────┘

PK = clave primaria · FK = clave foránea · ⚷ = único

Las decisiones que importan (van a tu DECISIONES.md y al README):

  • Un reactivo tiene muchos lotes. "Taq polimerasa" es una cosa; el frasco TAQ-2401 que vence en agosto es otra. Si los mezclas en una tabla, no puedes tener dos frascos del mismo reactivo con vencimientos distintos.
  • La clave foránea es una promesa. lote.reactivo_id apunta a un reactivo que existe. La base rechaza un lote de un reactivo fantasma; Excel te lo acepta encantado.
  • El stock se mueve por eventos. Movimiento registra cada entrada (+) y salida (−) con fecha y nota. lote.cantidad es el saldo actual; el historial te dice quién sacó qué y cuándo. Es tu bitácora, pero estructurada.
  • Cuatro tablas bien pensadas > una de 40 columnas. Si te descubres agregando cantidad_2 o vencimiento_nuevo, el esquema está gritando que falta una tabla.
Regla práctica para diseñar: nombra las cosas de tu mundo (reactivo, lote, ubicación, movimiento), pregunta "¿de esto puede haber varios por cada uno de aquello?" y donde la respuesta sea sí, hay dos tablas y una clave foránea entre ellas.

Paso 2 Crear el proyecto

mkdir inventario-lab
cd inventario-lab
git init
uv python pin 3.12
mkdir -p src/inventario tests
pyproject.toml
[project]
name = "inventario"
version = "0.1.0"
description = "Inventario de reactivos de laboratorio (proyectos 3–10 del cuaderno)"
requires-python = ">=3.12"
dependencies = []

[project.scripts]
inv = "inventario.cli:app"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/inventario"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
line-length = 100
extend-exclude = ["migrations"]  # código generado por alembic: no lo reescribimos a mano

Novedad respecto al P1: [project.scripts] declara que instalar este paquete crea el comando inv, que ejecuta app del módulo inventario.cli. El mismo mecanismo que hace que pytest exista como comando.

touch src/inventario/__init__.py
uv add sqlmodel typer alembic
uv add --dev pytest ruff
Deberías ver
Installed 12 packages in …
 + alembic==1.19.1
 + sqlmodel==0.0.39
 + typer==0.27.1
 …

Copia el .gitignore del P1 y agrégale una línea: *.db. La base de datos local no se versiona, igual que .venv/: se regenera (con migraciones) y contiene datos, no código.

Tres dependencias, tres porqués. sqlmodel: define tablas como clases Python (es Pydantic + SQLAlchemy, de la misma gente que FastAPI, que usarás en P4). typer: el CLI, como en P1. alembic: migraciones, el git del esquema. Cada una va a DECISIONES.md con su alternativa (sqlite3 a pelo, argparse, "ALTER TABLE a mano").
git add . && git commit -m "Estructura del proyecto inventario-lab"

Paso 3 Modelos y errores del dominio

El esquema del paso 1, ahora en código. Cada clase es una tabla; cada atributo, una columna:

src/inventario/models.py
"""Modelo de datos del inventario: qué existe y cómo se relaciona.

Ubicacion 1---* Lote *---1 Reactivo
                 |
                 *
             Movimiento
"""

from datetime import date, datetime

from sqlmodel import Field, SQLModel


class Ubicacion(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    nombre: str = Field(unique=True, index=True)  # "Congelador -20 A"


class Reactivo(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    nombre: str = Field(index=True)  # "Taq polimerasa"
    cas: str | None = None  # número CAS, si aplica
    unidad: str  # "µL", "mL", "g", "unidades"


class Lote(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    reactivo_id: int = Field(foreign_key="reactivo.id")
    ubicacion_id: int = Field(foreign_key="ubicacion.id")
    codigo: str = Field(unique=True, index=True)  # "TAQ-2401"
    cantidad: float  # en la unidad del reactivo
    vencimiento: date


class Movimiento(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    lote_id: int = Field(foreign_key="lote.id")
    cantidad: float  # negativo = salida, positivo = entrada
    fecha: datetime  # siempre en UTC
    nota: str | None = None

Y los errores con nombre del dominio. Que el código hable el idioma del laboratorio, no el de la base de datos:

src/inventario/errors.py
"""Errores propios del inventario. Nombran el problema en el idioma del laboratorio."""


class InventarioError(Exception):
    """Base de todos los errores del inventario."""


class LoteNoExiste(InventarioError):
    pass


class StockInsuficiente(InventarioError):
    pass
Por qué una jerarquía de dos niveles. Quien llama puede capturar lo específico (except StockInsuficiente) o todo lo del inventario (except InventarioError), y nunca por accidente un KeyError ajeno. Es la versión disciplinada de lo que viste en definir excepciones.

Paso 4 Conexión y sesión: dónde vive la transacción

src/inventario/db.py
"""Conexión a la base de datos. La URL viene del entorno; por defecto, SQLite local."""

import os
from collections.abc import Iterator
from contextlib import contextmanager

from sqlmodel import Session, SQLModel, create_engine

DEFAULT_URL = "sqlite:///inventario.db"


def get_engine(url: str | None = None, echo: bool = False):
    url = url or os.environ.get("INVENTARIO_DB_URL", DEFAULT_URL)
    return create_engine(url, echo=echo)


_engine = None


def engine():
    """Un solo engine por proceso (se crea la primera vez que se pide)."""
    global _engine
    if _engine is None:
        _engine = get_engine()
    return _engine


@contextmanager
def get_session(eng=None) -> Iterator[Session]:
    """Sesión con transacción: commit si todo va bien, rollback si algo falla."""
    with Session(eng or engine()) as session:
        try:
            yield session
            session.commit()
        except Exception:
            session.rollback()
            raise


def init_db(eng) -> None:
    """Crea las tablas directamente (para tests). En producción se usan las migraciones."""
    SQLModel.metadata.create_all(eng)
Este archivo es el corazón del concepto. get_session() es un context manager: todo lo que pase dentro del with es una transacción. Si el bloque termina bien → commit (se escribe todo). Si algo lanza una excepción → rollback (no se escribe nada). No hay estado intermedio posible. Fíjate también en INVENTARIO_DB_URL: la URL viene del entorno, no del código; en P6 cambiarás SQLite por Postgres tocando solo esa variable.

Paso 5 Migraciones con alembic

El esquema va a cambiar (te lo garantizo: en P6 y P7 agregas tablas). Una migración es un cambio de esquema versionado: un archivo que dice cómo pasar de la versión N a la N+1, commiteado en git como cualquier código.

uv run alembic init migrations

Eso crea alembic.ini y la carpeta migrations/. Tres ajustes de configuración:

  1. En alembic.ini, cambia la línea sqlalchemy.url por sqlalchemy.url = sqlite:///inventario.db.
  2. En migrations/env.py, importa tus modelos y apunta el metadata (así alembic "ve" tus tablas y puede autogenerar):
    migrations/env.py (fragmentos a cambiar)
    import os
    
    from sqlmodel import SQLModel
    
    import inventario.models  # noqa: F401  (registra las tablas en el metadata)
    
    # donde decía target_metadata = None:
    target_metadata = SQLModel.metadata
    
    # La URL puede venir del entorno (misma variable que usa la app)
    url_env = os.environ.get("INVENTARIO_DB_URL")
    if url_env:
        config.set_main_option("sqlalchemy.url", url_env)
  3. En migrations/script.py.mako, debajo de import sqlalchemy as sa agrega import sqlmodel (las migraciones autogeneradas usan tipos de sqlmodel).

Ahora la primera migración: alembic compara tus modelos con la base (vacía) y escribe el archivo por ti:

uv run alembic revision --autogenerate -m "esquema inicial: ubicacion, reactivo, lote, movimiento"
uv run alembic upgrade head
Deberías ver
INFO  [alembic.autogenerate.compare.tables] Detected added table 'ubicacion'
INFO  [alembic.autogenerate.compare.tables] Detected added table 'reactivo'
INFO  [alembic.autogenerate.compare.tables] Detected added table 'lote'
INFO  [alembic.autogenerate.compare.tables] Detected added table 'movimiento'
Generating …/migrations/versions/74582b9e0d28_esquema_inicial_….py ...  done

INFO  [alembic.runtime.migration] Running upgrade  -> 74582b9e0d28, esquema inicial: …

Abre el archivo generado y léelo (regla 4 del cuaderno: nada que no puedas explicar). Verás op.create_table(...) por cada modelo y un downgrade() que las borra. Comprueba qué existe ahora:

sqlite3 inventario.db ".tables"
Deberías ver
alembic_version  lote  movimiento  reactivo  ubicacion

alembic_version es la libreta donde alembic anota en qué versión va esta base. La segunda migración la harás en el paso 10, con datos adentro, para comprobar que no se pierden.

git add . && git commit -m "Modelos, sesión con transacción y migración inicial"

Paso 6 Los servicios: las reglas, test primero

Aquí vive la lógica que mañana usarán la API, el vigía y el MCP. Firma común: todas reciben la sesión como primer argumento y no hacen commit; quien abre la sesión decide cuándo se confirma. Primero los tests, con una base en memoria que nace y muere con cada test:

tests/conftest.py
"""Fixtures compartidas: una base de datos en memoria, nueva para cada test."""

from datetime import UTC, datetime, timedelta

import pytest
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine

from inventario import services


@pytest.fixture
def session():
    # sqlite:// sin ruta = en memoria. StaticPool: una sola conexión, para que
    # todas las sesiones del test vean la misma DB. Se crea y se destruye por test.
    engine = create_engine(
        "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
    )
    SQLModel.metadata.create_all(engine)
    with Session(engine) as s:
        yield s
    SQLModel.metadata.drop_all(engine)


@pytest.fixture
def datos(session):
    """Los datos de ejemplo del cuaderno: 2 ubicaciones, 3 reactivos, 3 lotes."""
    congelador = services.crear_ubicacion(session, "Congelador -20 A")
    estante = services.crear_ubicacion(session, "Estante 3")
    taq = services.crear_reactivo(session, "Taq polimerasa", "µL")
    etoh = services.crear_reactivo(session, "Etanol 96%", "mL", cas="64-17-5")
    aga = services.crear_reactivo(session, "Agarosa", "g", cas="9012-36-6")
    hoy = datetime.now(UTC).date()
    services.agregar_lote(session, taq.id, congelador.id, "TAQ-2401", 500, hoy + timedelta(days=10))
    services.agregar_lote(
        session, etoh.id, estante.id, "ETOH-2312", 2500, hoy + timedelta(days=400)
    )
    services.agregar_lote(session, aga.id, estante.id, "AGA-2405", 250, hoy + timedelta(days=200))
    session.commit()
    return {"taq": taq, "etoh": etoh, "aga": aga, "congelador": congelador, "estante": estante}

Los tests que definen las reglas. Fíjate en el quinto: es el corazón del proyecto.

tests/test_services.py
import pytest
from sqlmodel import select

from inventario import services
from inventario.errors import LoteNoExiste, StockInsuficiente
from inventario.models import Lote, Movimiento


def test_crear_reactivo_asigna_id(session):
    r = services.crear_reactivo(session, "Taq polimerasa", "µL")
    assert r.id is not None
    assert r.unidad == "µL"


def test_agregar_lote_registra_movimiento_de_alta(session, datos):
    movimientos = list(session.exec(select(Movimiento)))
    assert len(movimientos) == 3
    assert all(m.cantidad > 0 for m in movimientos)


def test_registrar_salida_descuenta_y_anota(session, datos):
    mov = services.registrar_salida(session, "TAQ-2401", 50, nota="PCR placa 12")
    lote = session.exec(select(Lote).where(Lote.codigo == "TAQ-2401")).one()
    assert lote.cantidad == 450
    assert mov.cantidad == -50
    assert mov.nota == "PCR placa 12"


def test_salida_de_lote_inexistente_falla(session, datos):
    with pytest.raises(LoteNoExiste):
        services.registrar_salida(session, "NO-EXISTE", 1)


def test_salida_mayor_al_stock_falla_y_no_deja_la_db_a_medias(session, datos):
    with pytest.raises(StockInsuficiente):
        services.registrar_salida(session, "TAQ-2401", 9999)
    session.rollback()  # lo que haría get_session() al ver la excepción
    lote = session.exec(select(Lote).where(Lote.codigo == "TAQ-2401")).one()
    assert lote.cantidad == 500  # intacto
    salidas = [m for m in session.exec(select(Movimiento)) if m.cantidad < 0]
    assert salidas == []  # ningún movimiento de salida quedó grabado


def test_salida_con_cantidad_negativa_falla(session, datos):
    with pytest.raises(ValueError):
        services.registrar_salida(session, "TAQ-2401", -5)


def test_lotes_por_vencer_solo_los_proximos(session, datos):
    codigos = [lote.codigo for lote in services.lotes_por_vencer(session, dias=30)]
    assert codigos == ["TAQ-2401"]


def test_lotes_por_vencer_ignora_lotes_agotados(session, datos):
    services.registrar_salida(session, "TAQ-2401", 500)
    assert services.lotes_por_vencer(session, dias=30) == []


def test_buscar_reactivo_ignora_mayusculas(session, datos):
    nombres = [r.nombre for r in services.buscar_reactivo(session, "taq")]
    assert nombres == ["Taq polimerasa"]


def test_buscar_reactivo_con_comilla_no_rompe_nada(session, datos):
    # Si esto se armara con f-string, la comilla rompería el SQL (o peor).
    assert services.buscar_reactivo(session, "' OR 1=1 --") == []

uv run pytest: rojo (services no existe). Ahora sí, la implementación:

src/inventario/services.py
"""Reglas del inventario. Aquí vive la lógica; el CLI (y luego la API) solo llaman a esto.

Todas las funciones reciben la sesión como primer argumento y NO hacen commit:
quien abre la sesión decide cuándo se confirma la transacción.
"""

from datetime import UTC, date, datetime, timedelta

from sqlmodel import Session, select

from inventario.errors import LoteNoExiste, StockInsuficiente
from inventario.models import Lote, Movimiento, Reactivo, Ubicacion


def crear_ubicacion(session: Session, nombre: str) -> Ubicacion:
    ubicacion = Ubicacion(nombre=nombre)
    session.add(ubicacion)
    session.flush()  # asigna el id sin cerrar la transacción
    return ubicacion


def crear_reactivo(session: Session, nombre: str, unidad: str, cas: str | None = None) -> Reactivo:
    reactivo = Reactivo(nombre=nombre, unidad=unidad, cas=cas)
    session.add(reactivo)
    session.flush()
    return reactivo


def agregar_lote(
    session: Session,
    reactivo_id: int,
    ubicacion_id: int,
    codigo: str,
    cantidad: float,
    vencimiento: date,
) -> Lote:
    if cantidad <= 0:
        raise ValueError("la cantidad inicial debe ser positiva")
    lote = Lote(
        reactivo_id=reactivo_id,
        ubicacion_id=ubicacion_id,
        codigo=codigo,
        cantidad=cantidad,
        vencimiento=vencimiento,
    )
    session.add(lote)
    session.flush()
    session.add(
        Movimiento(lote_id=lote.id, cantidad=cantidad, fecha=datetime.now(UTC), nota="alta")
    )
    return lote


def registrar_salida(
    session: Session, codigo_lote: str, cantidad: float, nota: str | None = None
) -> Movimiento:
    """Descuenta stock de un lote. Falla si el lote no existe o no alcanza.

    Las dos escrituras (restar al lote, anotar el movimiento) van en la misma
    transacción: o pasan las dos o no pasa ninguna.
    """
    if cantidad <= 0:
        raise ValueError("la cantidad a retirar debe ser positiva")
    lote = session.exec(select(Lote).where(Lote.codigo == codigo_lote)).first()
    if lote is None:
        raise LoteNoExiste(f"no existe el lote {codigo_lote!r}")
    if lote.cantidad < cantidad:
        raise StockInsuficiente(
            f"el lote {codigo_lote} tiene {lote.cantidad:g} y pediste {cantidad:g}"
        )
    lote.cantidad -= cantidad
    movimiento = Movimiento(lote_id=lote.id, cantidad=-cantidad, fecha=datetime.now(UTC), nota=nota)
    session.add(lote)
    session.add(movimiento)
    session.flush()
    return movimiento


def lotes_por_vencer(session: Session, dias: int = 30) -> list[Lote]:
    hoy = datetime.now(UTC).date()  # explícito en UTC: ver P6, "Fechas sin zona horaria"
    limite = hoy + timedelta(days=dias)
    consulta = (
        select(Lote)
        .where(Lote.vencimiento <= limite, Lote.cantidad > 0)
        .order_by(Lote.vencimiento)
    )
    return list(session.exec(consulta))


def buscar_reactivo(session: Session, texto: str) -> list[Reactivo]:
    consulta = select(Reactivo).where(Reactivo.nombre.ilike(f"%{texto}%"))  # parametrizado
    return list(session.exec(consulta))
uv run pytest -v
Deberías ver
tests/test_services.py::test_crear_reactivo_asigna_id PASSED             [ 18%]
tests/test_services.py::test_agregar_lote_registra_movimiento_de_alta PASSED [ 27%]
tests/test_services.py::test_registrar_salida_descuenta_y_anota PASSED   [ 36%]
tests/test_services.py::test_salida_de_lote_inexistente_falla PASSED     [ 45%]
tests/test_services.py::test_salida_mayor_al_stock_falla_y_no_deja_la_db_a_medias PASSED [ 54%]
tests/test_services.py::test_salida_con_cantidad_negativa_falla PASSED   [ 63%]
tests/test_services.py::test_lotes_por_vencer_solo_los_proximos PASSED   [ 72%]
tests/test_services.py::test_lotes_por_vencer_ignora_lotes_agotados PASSED [ 81%]
tests/test_services.py::test_buscar_reactivo_ignora_mayusculas PASSED    [ 90%]
tests/test_services.py::test_buscar_reactivo_con_comilla_no_rompe_nada PASSED [100%]

============================== 10 passed in 0.08s ==============================
Detalle que importa: session.flush() manda el INSERT a la base (y obtiene el id) pero no confirma la transacción; commit lo hace get_session() al final. Así agregar_lote puede usar lote.id para el movimiento y aun así todo sigue siendo una sola transacción. Y en el f-string de ilike(f"%{texto}%"): el texto es el valor del parámetro, no parte del SQL; el paso 8 te muestra la diferencia con las manos.

Paso 7 El CLI: una puerta de entrada delgada

src/inventario/cli.py
"""Puerta de entrada por línea de comandos. Solo traduce argumentos → servicios → texto."""

from datetime import date

import typer

from inventario import services
from inventario.db import get_session
from inventario.errors import InventarioError

app = typer.Typer(help="Inventario de reactivos del laboratorio", no_args_is_help=True)


@app.command("add-ubicacion")
def add_ubicacion(nombre: str):
    with get_session() as s:
        u = services.crear_ubicacion(s, nombre)
        typer.echo(f"Ubicación #{u.id}: {u.nombre}")


@app.command("add-reactivo")
def add_reactivo(nombre: str, unidad: str, cas: str | None = None):
    with get_session() as s:
        r = services.crear_reactivo(s, nombre, unidad, cas)
        typer.echo(f"Reactivo #{r.id}: {r.nombre} ({r.unidad})")


@app.command("add-lote")
def add_lote(reactivo_id: int, ubicacion_id: int, codigo: str, cantidad: float, vencimiento: str):
    """VENCIMIENTO en formato AAAA-MM-DD."""
    with get_session() as s:
        lote = services.agregar_lote(
            s, reactivo_id, ubicacion_id, codigo, cantidad, date.fromisoformat(vencimiento)
        )
        typer.echo(f"Lote {lote.codigo}: {lote.cantidad:g}, vence {lote.vencimiento}")


@app.command("take")
def take(codigo: str, cantidad: float, nota: str | None = None):
    """Registra una salida de stock."""
    try:
        with get_session() as s:
            services.registrar_salida(s, codigo, cantidad, nota)
            typer.echo(f"Salida de {cantidad:g} del lote {codigo}")
    except InventarioError as e:
        typer.echo(f"Error: {e}", err=True)
        raise typer.Exit(code=1)


@app.command("expiring")
def expiring(days: int = typer.Option(30, "--days")):
    """Lotes que vencen en los próximos DAYS días."""
    with get_session() as s:
        lotes = services.lotes_por_vencer(s, days)
        if not lotes:
            typer.echo(f"Nada vence en {days} días.")
        for lote in lotes:
            typer.echo(f"{lote.vencimiento}  {lote.codigo:<10} {lote.cantidad:g}")


@app.command("find")
def find(texto: str):
    with get_session() as s:
        for r in services.buscar_reactivo(s, texto):
            typer.echo(f"#{r.id}  {r.nombre} ({r.unidad})")


if __name__ == "__main__":
    app()

Pruébalo de punta a punta con los datos del cuaderno:

uv run inv add-ubicacion "Congelador -20 A"
uv run inv add-ubicacion "Estante 3"
uv run inv add-reactivo "Taq polimerasa" "µL"
uv run inv add-reactivo "Etanol 96%" "mL" --cas 64-17-5
uv run inv add-reactivo "Agarosa" "g" --cas 9012-36-6
uv run inv add-lote 1 1 TAQ-2401 500 2026-08-26
uv run inv add-lote 2 2 ETOH-2312 2500 2027-09-20
uv run inv add-lote 3 2 AGA-2405 250 2027-03-04
uv run inv take TAQ-2401 50 --nota "PCR placa 12"
uv run inv take TAQ-2401 9999
uv run inv expiring --days 30
uv run inv find taq
Deberías ver
Ubicación #1: Congelador -20 A
Ubicación #2: Estante 3
Reactivo #1: Taq polimerasa (µL)
Reactivo #2: Etanol 96% (mL)
Reactivo #3: Agarosa (g)
Lote TAQ-2401: 500, vence 2026-08-26
Lote ETOH-2312: 2500, vence 2027-09-20
Lote AGA-2405: 250, vence 2027-03-04
Salida de 50 del lote TAQ-2401
Error: el lote TAQ-2401 tiene 450 y pediste 9999
2026-08-26  TAQ-2401   450
#1  Taq polimerasa (µL)

Fíjate en la línea del error: el take imposible falló con mensaje claro y código de salida 1 (echo $? para verlo), y el stock quedó en 450, no en un número raro. La transacción del paso 4 trabajando.

Un test del CLI basta por ahora (la lógica ya está testeada en servicios; el CLI solo traduce):

tests/test_cli.py
from typer.testing import CliRunner

from inventario.cli import app

runner = CliRunner()


def test_cli_muestra_ayuda_sin_argumentos():
    resultado = runner.invoke(app, [])
    assert "add-lote" in resultado.output
    assert "expiring" in resultado.output
uv run pytest -q && uv run ruff check . && uv run ruff format .
git add . && git commit -m "Servicios con transacciones y CLI inv"
Deberías ver
11 passed in 0.08s
All checks passed!

Paso 8 El experimento: inyección SQL con tus propias manos

Regla del cuaderno: nunca SQL armado con f-strings. Para que no sea un dogma, rómpelo una vez en un entorno seguro y mira qué pasa. Crea experimentos/inyeccion.py (la carpeta experimentos/ es para esto: código de aprendizaje que no es parte del paquete):

experimentos/inyeccion.py
import sqlite3

con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE reactivo (nombre TEXT, confidencial TEXT)")
con.execute("INSERT INTO reactivo VALUES ('Taq polimerasa', 'no'), ('Cepa secreta X', 'sí')")

texto = "' OR 1=1 --"   # lo que "escribe el usuario"

# MAL: el texto entra al SQL y cambia la consulta
mal = f"SELECT nombre FROM reactivo WHERE nombre = '{texto}'"
print("consulta armada:", mal)
print("con f-string devuelve:", con.execute(mal).fetchall())

# BIEN: el texto viaja como parámetro; nunca es SQL
bien = con.execute("SELECT nombre FROM reactivo WHERE nombre = ?", (texto,)).fetchall()
print("parametrizado devuelve:", bien)
uv run python experimentos/inyeccion.py
Deberías ver
consulta armada: SELECT nombre FROM reactivo WHERE nombre = '' OR 1=1 --'
con f-string devuelve: [('Taq polimerasa',), ('Cepa secreta X',)]
parametrizado devuelve: []
Lée lo que pasó. El usuario "buscó" un texto rarísimo, y la versión f-string devolvió toda la tabla, incluida la fila confidencial: su comilla cerró la cadena del SQL, el OR 1=1 hizo verdadera la condición para todas las filas y el -- comentó el resto. La versión parametrizada devolvió []: buscó literalmente un reactivo llamado ' OR 1=1 --, que no existe. Tu ORM parametriza siempre (lo verificaste con el test de la comilla en el paso 6); el peligro reaparece si algún día escribes SQL crudo. En P4, cuando tu API reciba texto de internet, esto deja de ser teoría.

Paso 9 Índices y complejidad: por qué la búsqueda no se arrastra

En models.py pusiste index=True en Lote.codigo. ¿Qué compra eso? Mídelo con la versión Python del problema: buscar un código entre 100.000.

experimentos/indice.py
import timeit


def codigo(i):
    return f"L-{i:06d}"


lotes = [codigo(i) for i in range(100_000)]
objetivo = codigo(99_999)          # el peor caso para la lista: está al final
indice = set(lotes)                 # el "índice": pagar una vez para buscar rápido

t_lista = timeit.timeit(lambda: objetivo in lotes, number=1_000)
t_set = timeit.timeit(lambda: objetivo in indice, number=1_000)
print(f"en lista  (O(n)): {t_lista * 1000:8.1f} ms por 1000 búsquedas")
print(f"en set    (O(1)): {t_set * 1000:8.1f} ms por 1000 búsquedas")
print(f"factor: {t_lista / t_set:,.0f}×")
uv run python experimentos/indice.py
Deberías ver (los números varían; el orden de magnitud no)
en lista  (O(n)):    398.1 ms por 1000 búsquedas
en set    (O(1)):      0.0 ms por 1000 búsquedas
factor: 21,913×

Eso es un índice: una estructura auxiliar (como el set, o el índice alfabético de un libro) que se paga al escribir para no recorrer todo al leer. El de la base es un árbol (O(log n)), no un hash, pero la moraleja es la misma: si una consulta es lenta, la respuesta casi siempre es un índice, no más CPU.

Segunda mitad del paso: mira el SQL que el ORM genera por ti. Nada de magia tolerada:

uv run python -c "
from sqlmodel import select
from inventario.models import Reactivo
print(select(Reactivo).where(Reactivo.nombre.ilike('%taq%')))"
Deberías ver
SELECT reactivo.id, reactivo.nombre, reactivo.cas, reactivo.unidad
FROM reactivo
WHERE lower(reactivo.nombre) LIKE lower(:nombre_1)

Ahí está todo: la lista de columnas, el filtro, y :nombre_1, el hueco del parámetro (tu texto nunca se pega al SQL). Si quieres ver cada consulta mientras la app corre, crea el engine con echo=True (get_engine(echo=True)) y corre un comando del CLI: ese chorro de SQL es exactamente lo que tu programa le dice a la base.

git add . && git commit -m "Experimentos: inyección SQL e índice vs lista"

Paso 10 Compose, volumen, y la segunda migración

Docker igual que en P0/P1, con una novedad: la base tiene que sobrevivir al contenedor. Para eso existe el volumen.

Dockerfile
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock alembic.ini ./
COPY migrations ./migrations
COPY src ./src
COPY tests ./tests
RUN uv sync --frozen
# Al arrancar: aplica migraciones y muestra la ayuda del CLI
CMD ["sh", "-c", "uv run alembic upgrade head && uv run inv --help"]
compose.yaml
services:
  inv:
    build: .
    environment:
      INVENTARIO_DB_URL: sqlite:////data/inventario.db
    volumes:
      - datos:/data      # la DB vive en un volumen: sobrevive al contenedor

volumes:
  datos:

Agrega *.db también al .dockerignore (tu base local no debe colarse en la imagen). Ahora la prueba de persistencia, en tres actos:

# Acto 1: migra y escribe en el volumen
docker compose run --rm inv sh -c "uv run alembic upgrade head && uv run inv add-ubicacion 'Congelador -20 A'"

# Acto 2: OTRO contenedor, mismo volumen: el dato sigue ahí
docker compose run --rm inv sh -c "uv run inv add-ubicacion 'Congelador -20 A'"
Deberías ver
# acto 1:
INFO  [alembic.runtime.migration] Running upgrade  -> 74582b9e0d28, esquema inicial: …
Ubicación #1: Congelador -20 A

# acto 2:
IntegrityError: UNIQUE constraint failed: ubicacion.nombre

Ese error del acto 2 es la prueba: el segundo contenedor es nuevo, pero encontró la ubicación creada por el primero (por eso el unique de nombre protestó). Si borras el volumen (docker compose down -v) y repites, el acto 2 vuelve a funcionar: contenedor efímero, datos en el volumen. Esa es la respuesta a "¿por qué la DB se borra al reiniciar?" de la ficha de hábitos.

Cierre del proyecto: la segunda migración, con datos adentro. Agrega un campo a Ubicacion en models.py:

src/inventario/models.py (solo la línea nueva en Ubicacion)
class Ubicacion(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    nombre: str = Field(unique=True, index=True)  # "Congelador -20 A"
    descripcion: str | None = None  # agregada en la 2ª migración
uv run alembic revision --autogenerate -m "ubicacion.descripcion"
uv run alembic upgrade head
uv run inv expiring --days 30
sqlite3 inventario.db "select nombre, descripcion from ubicacion"
Deberías ver
INFO  [alembic.autogenerate.compare.tables] Detected added column 'ubicacion.descripcion'
Generating …/migrations/versions/da478409a61c_ubicacion_descripcion.py ...  done
INFO  [alembic.runtime.migration] Running upgrade 74582b9e0d28 -> da478409a61c, ubicacion.descripcion
2026-08-26  TAQ-2401   450
Congelador -20 A|
Estante 3|

La columna nueva existe, los lotes y ubicaciones siguen intactos. Eso, hecho "a mano con ALTER TABLE en la base de prod", es una de las historias de terror favoritas de Rodrigo; pregúntale.

git add . && git commit -m "Compose con volumen y segunda migración (ubicacion.descripcion)"
git switch -c feat/inventario-base && git push -u origin feat/inventario-base
gh pr create --title "Inventario base: modelos, servicios, CLI, migraciones" --reviewer rotorrest

(Si vienes commiteando en main local, este PR agrupa el proyecto entero; también es válido —y mejor— haber hecho un PR por paso grande. Decide con Rodrigo cuál practicar.)

Entiende el código

Antes del P4, estas cinco ideas tienen que estar firmes.

Estructura

inventario-lab/
├── alembic.ini              config de migraciones (URL de la base)
├── migrations/
│   ├── env.py               conecta alembic con tus modelos
│   └── versions/            una migración = un archivo versionado en git
├── src/inventario/
│   ├── models.py            el esquema: qué existe (4 tablas)
│   ├── errors.py            errores del dominio
│   ├── db.py                engine + get_session (la transacción vive aquí)
│   ├── services.py          las reglas (sin commit, sin print, sin typer)
│   └── cli.py               puerta de entrada: argumentos → servicios → texto
├── tests/
│   ├── conftest.py          DB en memoria por test + datos de ejemplo
│   ├── test_services.py     las reglas, incluida la transacción
│   └── test_cli.py
├── experimentos/            código de aprendizaje, no del paquete
├── Dockerfile · compose.yaml · .dockerignore
└── README.md · DECISIONES.md

Las capas, y quién puede hablar con quién

cli.pyservices.pymodels.py/db.py. El CLI no sabe SQL; los servicios no saben de terminal (cero typer, cero print); los modelos no saben de nada. En P4 vas a agregar api/ a la izquierda de services.py y no vas a tocar ni una línea de servicios. Si hoy metieras la regla del stock en cli.py, en P4 la copiarías a la API y en P7 al vigía: tres copias, tres lugares donde olvidar el fix. Ese es el antipatrón "copiar y pegar con cambios chicos" aplicado a arquitectura.

Transacción: todo o nada

registrar_salida hace dos escrituras: resta al lote y agrega el movimiento. Sin transacción, un fallo entre ambas deja el mundo inconsistente (stock descontado sin registro, o registro sin descuento). Con get_session(), la excepción de StockInsuficiente dispara el rollback y la base queda como si nada hubiera pasado; lo probaste con test_salida_mayor_al_stock…. Vocabulario: a esta garantía se le llama atomicidad (la A de ACID); no necesitas la sigla todavía, sí la idea.

Migración = commit del esquema

El esquema cambia con el tiempo igual que el código. Una migración es ese cambio escrito como código, con su inverso (downgrade), aplicable en orden en cualquier copia de la base: tu laptop, la de Rodrigo, prod. La tabla alembic_version es el "HEAD" de la base. Nunca ALTER TABLE a mano: la migración es la única puerta.

Índice: pagar al escribir para no pagar al leer

Tu experimento: lista 398 ms, set ~0 ms, factor ~22.000×. El índice de la base es lo mismo con otra estructura (un árbol B: O(log n), y además sirve para rangos como vencimiento <= fecha). Costo: cada INSERT/UPDATE también actualiza el índice, y ocupa disco. Por eso no se indexa todo: se indexa lo que se busca (codigo, nombre). Misma idea que verás en P9 con el índice vectorial, y la que usa BLAST con sus k-mers.

Con Claude

A esta altura Claude ya puede escribir código en tus proyectos, con tus reglas. Lo que sigue siendo tuyo, a mano: el esquema (paso 1), db.py y registrar_salida con sus tests; son el concepto del proyecto. Actualiza el CLAUDE.md:

CLAUDE.md
# inventario-lab

Inventario de reactivos. Base del sistema de los proyectos 3–10 del cuaderno.

## Arquitectura (no negociable)
- cli.py → services.py → models.py/db.py. La lógica SOLO en services.
- services reciben la sesión como primer argumento y NO hacen commit.
- El esquema cambia SOLO por migraciones de alembic.

## Reglas
- No escribas código sin que te lo pida. Explica primero.
- Cambios chicos, de a un archivo. Nunca toques los tests para que pasen.
- Nada de SQL crudo con f-strings. Consultas parametrizadas siempre.
- No agregues dependencias sin preguntar.
- Antes de dar algo por listo: uv run pytest && uv run ruff check .
- Responde en español.

Pedidos que sí valen la pena en este proyecto

Critica mi esquema [pegar el diagrama del paso 1]: ¿qué consulta futura me va a doler con este diseño? ¿Qué agregarías tú y por qué? No cambies nada aún. Explícame qué hace session.flush() y en qué se diferencia de session.commit(), con un ejemplo de este repo. Escribe dos tests más para lotes_por_vencer que cubran casos borde que yo no cubrí. Explica por qué elegiste esos casos antes de escribirlos. Abre migrations/versions/ y explícame la migración autogenerada línea por línea. ¿Qué haría el downgrade y cuándo lo usaría? ¿Qué pasa si dos personas corren `inv take TAQ-2401 300` al mismo tiempo con stock 450? Explícame la carrera y qué opciones hay en SQLite. No implementes nada.

La última pregunta no tiene arreglo perfecto en este proyecto (SQLite serializa escrituras; el problema real aparece con Postgres y varios clientes en P6). Lo que importa es que la conversación exista y quede anotada en DECISIONES.md como pendiente consciente.

Si algo falla

alembic revision --autogenerate genera una migración vacía (pass en upgrade)

Alembic no está viendo tus modelos. Revisa en migrations/env.py: (1) import inventario.models presente (es lo que registra las tablas), (2) target_metadata = SQLModel.metadata. Si ya migraste antes y no hay cambios nuevos, la migración vacía es correcta: no hay nada que detectar.

NameError: name 'sqlmodel' is not defined al correr alembic upgrade head

La migración autogenerada usa sqlmodel.sql.sqltypes.AutoString pero el template no lo importa. Agrega import sqlmodel en migrations/script.py.mako (paso 5.3) y regenera la migración (borra el archivo en versions/ y repite el revision --autogenerate).

Los tests fallan con no such table: reactivo

La fixture no creó las tablas en la base en memoria. Revisa que conftest.py llame a SQLModel.metadata.create_all(engine) después de que los modelos se hayan importado (el from inventario import services de arriba ya los importa). Si escribiste tu propia fixture, importa inventario.models antes del create_all.

En los tests, lo que escribo con una sesión no aparece al leer con otra

Con sqlite:// en memoria, cada conexión nueva ve una base nueva y vacía. Por eso la fixture usa poolclass=StaticPool: fuerza una sola conexión compartida. Si quitaste esa línea, vuelve a ponerla.

sqlite3.IntegrityError: FOREIGN KEY constraint failed … o peor: NO falla cuando debería

Dato incómodo de SQLite: las claves foráneas existen pero no se verifican salvo que la conexión lo active (PRAGMA foreign_keys=ON). SQLAlchemy no lo activa por defecto. Compruébalo: inserta un lote con reactivo_id=999 y mira si protesta. Si te importa el chequeo estricto (te importa), pregúntale a Claude cómo activar el PRAGMA con un event listener del engine, entiéndelo y anótalo en DECISIONES.md. En Postgres (P6) esto es siempre estricto; otra razón para migrar.

uv run inv … dice error: Failed to spawn: `inv`

El comando inv lo crea [project.scripts] al instalar el paquete. Si agregaste esa sección después del primer uv add, reinstala: uv sync --reinstall-package inventario. Verifica con uv run which inv (debe apuntar a .venv/bin/inv).

En compose: unable to open database file

La URL sqlite:////data/inventario.db lleva cuatro barras (tres de sqlite:/// + la ruta absoluta /data/…), y la carpeta /data debe existir: la crea el volumen del compose. Si corres con docker run a mano, monta el volumen tú: docker run -v datos:/data -e INVENTARIO_DB_URL=sqlite:////data/inventario.db ….

Listo cuando

Las mismas casillas que en el índice; marcarlas aquí las marca allá.

La pregunta de Rodrigo en la sesión

"registrar_salida resta el stock y luego agrega el movimiento. Si invierto el orden de esas dos líneas, ¿cambia algo? ¿Y si el proceso muere entre las dos?" Pista: la respuesta correcta usa la palabra transacción y explica quién hace el commit.

Siguiente

En el Proyecto 4 · API con FastAPI le pones a este mismo inventario una puerta HTTP: POST /movimientos hará exactamente lo que hoy hace inv take, llamando al mismo services.registrar_salida. Cero lógica duplicada: esa es la prueba de que las capas de hoy están bien cortadas. También vas a descubrir por qué StockInsuficiente se convierte en un 409 y LoteNoExiste en un 404.

Si te quedaste con ganas: abre sqlite3 inventario.db y juega: .schema lote, select * from movimiento;, un join entre lote y reactivo. Hablarle a la base sin ORM te quita el miedo y te enseña qué hace el ORM por ti.