Los diccionarios son útiles, pero cuando representan entidades importantes pueden volverse ambiguos. No sabemos qué claves son obligatorias, qué tipo tiene cada valor ni qué estructura se espera sin leer todo el código.
En este tema usaremos dataclasses y type hints para aclarar estructuras de datos. Refactorizaremos código que usa diccionarios hacia modelos más explícitos, manteniendo pruebas y verificaciones con mypy.
Los tipos ayudan a documentar qué espera una función y qué devuelve. No reemplazan las pruebas, pero detectan errores antes de ejecutar el programa y hacen más claro el contrato entre partes del código.
En Python, los type hints son graduales: podemos agregarlos poco a poco. No hace falta tipar todo el proyecto en un solo paso.
Crea el archivo src/catalogo.py:
def calcular_precio_final(producto):
precio = producto["precio"]
if producto["categoria"] == "software":
precio = precio * 0.9
if producto["stock"] == 0:
precio = precio * 0.8
return round(precio, 2)
def crear_etiqueta(producto):
return producto["codigo"] + " - " + producto["nombre"]
El código espera claves específicas, pero la estructura de producto no está declarada en ningún lugar.
Crea tests/test_catalogo.py:
from catalogo import calcular_precio_final, crear_etiqueta
def test_calcula_precio_final_de_software_con_stock():
producto = {
"codigo": "SW-1",
"nombre": "Editor Pro",
"categoria": "software",
"precio": 10000,
"stock": 5,
}
assert calcular_precio_final(producto) == 9000.0
def test_crea_etiqueta():
producto = {
"codigo": "BK-1",
"nombre": "Python Básico",
"categoria": "libro",
"precio": 5000,
"stock": 3,
}
assert crear_etiqueta(producto) == "BK-1 - Python Básico"
Ejecuta:
python -m pytest tests/test_catalogo.py
Ahora declaramos la estructura de Producto:
from dataclasses import dataclass
@dataclass
class Producto:
codigo: str
nombre: str
categoria: str
precio: float
stock: int
La clase deja claro qué datos componen un producto. Además, Python genera automáticamente el constructor y una representación útil para depuración.
Para migrar de forma gradual, creamos funciones nuevas que reciben Producto:
def calcular_precio_final_v2(producto: Producto) -> float:
precio = producto.precio
if producto.categoria == "software":
precio = precio * 0.9
if producto.stock == 0:
precio = precio * 0.8
return round(precio, 2)
def crear_etiqueta_v2(producto: Producto) -> str:
return producto.codigo + " - " + producto.nombre
Los accesos por atributo son más explícitos que las claves de diccionario y los tipos documentan el contrato.
Agrega pruebas usando la dataclass:
from catalogo import Producto, calcular_precio_final, calcular_precio_final_v2, crear_etiqueta
def test_calcula_precio_final_v2_de_software_con_stock():
producto = Producto(
codigo="SW-1",
nombre="Editor Pro",
categoria="software",
precio=10000,
stock=5,
)
assert calcular_precio_final_v2(producto) == 9000.0
Ejecuta python -m pytest tests/test_catalogo.py. Las funciones viejas y nuevas pueden convivir durante la migración.
Si todavía recibimos diccionarios desde otra parte del sistema, podemos convertirlos:
def producto_desde_dict(datos: dict) -> Producto:
return Producto(
codigo=datos["codigo"],
nombre=datos["nombre"],
categoria=datos["categoria"],
precio=datos["precio"],
stock=datos["stock"],
)
Luego la función vieja puede delegar:
def calcular_precio_final(producto):
return calcular_precio_final_v2(producto_desde_dict(producto))
Cuando una función recibe una lista de productos, también podemos declararlo:
def calcular_valor_inventario(productos: list[Producto]) -> float:
total = 0
for producto in productos:
total = total + producto.precio * producto.stock
return round(total, 2)
Esta firma comunica que la lista debe contener objetos Producto, no diccionarios ni strings.
Si tienes mypy instalado en el entorno del proyecto, puedes ejecutar:
mypy src
También puedes llamarlo a través de Python si está instalado como módulo:
python -m mypy src
mypy revisa anotaciones de tipo y puede detectar llamadas incompatibles antes de ejecutar las pruebas.
Podemos definir valores por defecto cuando representan el caso común:
@dataclass
class Producto:
codigo: str
nombre: str
categoria: str
precio: float
stock: int = 0
Los campos sin valor por defecto deben ir antes que los campos con valor por defecto.
En una dataclass, no uses una lista vacía directamente como valor por defecto:
# Evitar
@dataclass
class Pedido:
items: list = []
Usa field(default_factory=list):
from dataclasses import dataclass, field
@dataclass
class Pedido:
items: list[Producto] = field(default_factory=list)
Así cada instancia recibe su propia lista.
Si un objeto representa un valor que no debería cambiar, podemos usar frozen=True:
@dataclass(frozen=True)
class CodigoProducto:
valor: str
Esto ayuda a evitar modificaciones accidentales. Es especialmente útil en objetos de valor como códigos, fechas, porcentajes o dinero.
El tipado gradual funciona mejor si se aplica alrededor del código que estamos refactorizando. Una estrategia segura es:
mypy.Crea el archivo src/alumnos_modelo.py:
def calcular_promedio(alumno):
notas = alumno["notas"]
if not notas:
return 0
return round(sum(notas) / len(notas), 2)
def crear_resumen(alumno):
return alumno["legajo"] + " - " + alumno["nombre"]
Realiza estas tareas:
Alumno con type hints.Alumno.field(default_factory=list) si defines una lista por defecto.python -m pytest y luego python -m mypy src.Una solución posible es:
from dataclasses import dataclass, field
@dataclass
class Alumno:
legajo: str
nombre: str
notas: list[float] = field(default_factory=list)
def alumno_desde_dict(datos: dict) -> Alumno:
return Alumno(
legajo=datos["legajo"],
nombre=datos["nombre"],
notas=datos.get("notas", []),
)
def calcular_promedio_v2(alumno: Alumno) -> float:
if not alumno.notas:
return 0
return round(sum(alumno.notas) / len(alumno.notas), 2)
def crear_resumen_v2(alumno: Alumno) -> str:
return alumno.legajo + " - " + alumno.nombre
def calcular_promedio(alumno):
return calcular_promedio_v2(alumno_desde_dict(alumno))
def crear_resumen(alumno):
return crear_resumen_v2(alumno_desde_dict(alumno))
Las funciones viejas siguen funcionando con diccionarios, mientras el código nuevo puede usar Alumno.
Antes de continuar, verifica que puedes explicar estos puntos:
field(default_factory=list).frozen=True.En este tema usamos dataclasses y type hints para aclarar estructuras de datos. Pasamos de diccionarios ambiguos a modelos explícitos que comunican mejor qué datos existen y cómo se usan.
En el próximo tema reemplazaremos condicionales de tipo por polimorfismo simple en Python.