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 verInstalled 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:
- En
alembic.ini, cambia la línea sqlalchemy.url por sqlalchemy.url = sqlite:///inventario.db.
- 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)
- 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 verINFO [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 veralembic_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.pyimport 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))
Deberías vertests/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 verUbicació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.pyfrom 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 ver11 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.pyimport 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 verconsulta 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.pyimport 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 verSELECT 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.
DockerfileFROM 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.yamlservices:
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 verINFO [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.py → services.py → models.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.