ruta backend / módulo 1

M1 — Python idiomático

Outcome del módulo: escribir Python que un senior firmaría, y detectar código no-idiomático a simple vista.


Plantilla de concepto (formato del curso)

Cada concepto sigue esta estructura. Copiala para cualquier concepto nuevo:

### <Concepto>
Idea núcleo (1 línea)
── Entender (capa 1) ──
  · Texto     : explicación mínima
  · Visual    : diagrama (Mermaid)
  · Video     : canal/búsqueda recomendada
  · Ejemplo   : código trabajado (mal → bien)
── Fijar (capa 2) ──
  · Nota atómica: front / back (preguntable)
  · Feynman   : explicalo como a un junior
── Aplicar (capa 3) ──
  · Micro-ejercicio
── Límites ──
  · Cuándo NO usarlo / qué se rompe

Fuentes de video recomendadas (verificar antes de fijar como oficiales): canales mCoding, ArjanCodes, Real Python (YouTube) — idioms y diseño Python de nivel serio. Marcar [verificar] hasta confirmar el video puntual.


Concepto 1 — Generadores (evaluación perezosa)

Idea núcleo: un generador produce valores de a uno, bajo demanda, sin cargar todo en memoria.

Entender (capa 1)

Texto: una lista calcula y guarda todos sus elementos ya. Un generador (yield o (x for x in ...)) calcula el siguiente solo cuando se lo pedís. Para 10 millones de filas: la lista te revienta la RAM; el generador procesa una a la vez.

Visual:

flowchart LR
    subgraph Lista["Lista (eager)"]
        A[calcula TODO] --> B[guarda TODO en RAM] --> C[itera]
    end
    subgraph Gen["Generador (lazy)"]
        D[pide 1] --> E[calcula 1] --> F[entrega 1] --> D
    end

Video: Python Generators — mCoding

Ejemplo (mal → bien):

def leer_ids_mal(filas):
    resultado = []
    for f in filas:
        resultado.append(f["id"])
    return resultado          # carga TODO en memoria

def leer_ids_bien(filas):
    for f in filas:
        yield f["id"]          # una a la vez, memoria constante

# uso idéntico, memoria radicalmente distinta
for id_ in leer_ids_bien(millones_de_filas):
    procesar(id_)

Fijar (capa 2)

Nota atómica:

Feynman: “Una lista es cocinar todo el buffet antes de que llegue nadie. Un generador es cocinar cada plato cuando el comensal lo pide. Si son 10.000 comensales, el buffet no cabe en la cocina.”

Aplicar (capa 3)

Escribí una función generadora que lea un archivo grande línea por línea y entregue solo las que matchean un patrón, sin cargar el archivo entero.

Límites


Concepto 2 — El GIL, async y multiprocessing (el trío de entrevista)

Idea núcleo: el GIL deja correr un solo hilo de bytecode Python a la vez. Por eso async/threads aceleran I/O-bound pero no CPU-bound; ahí va multiprocessing.

Entender (capa 1)

Texto: el Global Interpreter Lock serializa la ejecución de bytecode. Dos tareas:

Visual:

flowchart TD
    T{¿espera I/O o calcula CPU?} -->|I/O-bound| IO[async o threads<br/>✓ acelera]
    T -->|CPU-bound| CPU[multiprocessing<br/>✓ acelera]
    CPU -.->|async/threads NO sirven acá| BAD[✗ asyncio = 1 hilo · threads = GIL los serializa]

Video: Python’s Global Interpreter Lock (GIL): Concurrency, Threading & Multitasking — Real Python

Ejemplo:

# I/O-bound: async ACELERA (mientras una espera la red, otra corre)
async def fetch_all(urls):
    async with aiohttp.ClientSession() as s:
        return await asyncio.gather(*(s.get(u) for u in urls))

# CPU-bound: async NO ayuda; procesos SÍ
from multiprocessing import Pool
def hash_pesado(x): ...
with Pool() as p:
    resultados = p.map(hash_pesado, datos)

Fijar (capa 2)

Nota atómica:

Feynman: “El GIL es un solo micrófono en una sala. Si todos esperan una llamada telefónica (I/O), se pasan el micro y avanza el grupo. Si todos tienen que hablar sin parar (CPU), el micro único los hace esperar en fila igual. Solución: darle a cada uno su propia sala (proceso).”

Aplicar (capa 3)

Tomá una función que sume/calcule sobre una lista enorme. Medí (time) con un loop normal, con asyncio, y con multiprocessing.Pool. Comprobá vos mismo cuál acelera y cuál no.

Límites


Concepto 3 — Context managers (with)

Idea núcleo: garantizan setup/teardown (abrir/cerrar, adquirir/liberar) aunque haya excepción.

Entender: with llama __enter__ al entrar y __exit__ al salir, siempre (incluso si explota). Reemplaza el try/finally repetitivo.

# frágil: si algo explota entre open y close, el archivo queda abierto
f = open("x"); data = f.read(); f.close()
# idiomático: se cierra pase lo que pase
with open("x") as f:
    data = f.read()

Crear el tuyo con contextlib.contextmanager:

from contextlib import contextmanager
@contextmanager
def timer():
    import time; t = time.perf_counter()
    try:
        yield
    finally:                                  # corre aun si el bloque lanza excepción
        print(f"{time.perf_counter()-t:.3f}s")

Video: Building A Custom Context Manager In Python: A Closer Look — ArjanCodes Nota atómica: Front: ¿qué garantiza with que un try/finally manual olvida? Back: el teardown corre siempre, aun con excepción; menos código, imposible olvidar el cierre. Feynman: “Es un contrato: al entrar te presto algo, al salir lo recupero sí o sí, aunque te caigas.” Aplicar: escribí un context manager que abra una transacción de DB y haga commit al salir bien / rollback si hay excepción. Límites: no lo fuerces donde no hay un par adquirir/liberar real.


Concepto 4 — Decorators

Idea núcleo: una función que envuelve a otra para agregarle comportamiento sin tocar su código.

Entender:

from functools import wraps
def log_calls(fn):
    @wraps(fn)                      # preserva nombre/docstring del original
    def wrapper(*args, **kwargs):
        print(f"llamando {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

@log_calls
def sumar(a, b): return a + b

Visual:

flowchart LR
    call[llamada] --> wrapper[wrapper: antes] --> orig[función original] --> after[wrapper: después] --> ret[retorno]

Video: Python Decorators: The Complete Guide — ArjanCodes Nota atómica: Front: ¿para qué sirve functools.wraps? Back: copia metadata (__name__, __doc__) de la función original al wrapper; sin él, el debug y las tools ven “wrapper”. Feynman: “Es papel de regalo con lógica: la caja adentro es la misma función, pero el envoltorio hace algo antes/después.” Aplicar: decorator @retry(3) que reintente una función hasta 3 veces si lanza excepción. Límites: demasiados decorators apilados = magia difícil de debuggear.


Concepto 5 — Typing + dataclasses/pydantic

Idea núcleo: los type hints documentan y habilitan chequeo estático; dataclasses/pydantic modelan datos sin boilerplate.

Entender:

from dataclasses import dataclass
@dataclass
class User:
    id: int
    name: str
    active: bool = True          # sin __init__ manual, con __eq__/__repr__ gratis

Concepto 6 — Trampas clásicas (bugs que todos pisan una vez)

Idea núcleo: tres comportamientos contra-intuitivos que delatan al que no domina Python.

Entender:

# 1) Mutable default argument: el default se crea UNA vez, se comparte entre llamadas
def add(x, acc=[]):      # ❌ acc persiste entre llamadas
    acc.append(x); return acc
def add(x, acc=None):    # ✅
    acc = [] if acc is None else acc   # NO uses `acc or []`: descartaría una lista vacía pasada aposta
    acc.append(x); return acc

# 2) Late binding en closures
fns = [lambda: i for i in range(3)]   # ❌ todas devuelven 2
fns = [lambda i=i: i for i in range(3)]# ✅ captura por valor

# 3) is vs ==
a == b   # ¿mismo valor?
a is b   # ¿mismo objeto en memoria? (usar solo con None: x is None)

Nota atómica: Front: ¿por qué def f(x=[]) es un bug? Back: el default se evalúa una sola vez al definir la función; la misma lista se reusa en cada llamada → estado compartido inesperado. Usar None centinela. Feynman: “El default mutable es como un carrito de compras que creés que es nuevo cada vez, pero es el mismo carrito para todos los clientes.” Aplicar: reproducí los tres bugs, después arreglalos. Escribí el porqué de cada uno. Límites: is solo para singletons (None, True, False), nunca para comparar valores.


Concepto 7 — pytest en serio

Idea núcleo: tests legibles con fixtures (setup reusable), parametrize (mismos asserts, muchos inputs) y mocking (aislar dependencias).

Entender:

import pytest

@pytest.fixture
def db():                       # setup reusable, se inyecta por nombre
    conn = crear_db_temporal(); yield conn; conn.close()

@pytest.mark.parametrize("entrada,esperado", [(2,4),(3,9),(4,16)])
def test_cuadrado(entrada, esperado):
    assert cuadrado(entrada) == esperado

def test_llama_api(mocker):    # aislar la red
    m = mocker.patch("modulo.requests.get")
    ...

Video: Pytest Tutorial – How to Test Python Code — freeCodeCamp.org Nota atómica: Front: ¿qué resuelve parametrize? Back: correr el mismo test con muchos pares input/output sin duplicar código; cada caso reporta por separado. Feynman: “fixture = el mise en place que preparás una vez y usás en varios platos. parametrize = probar la misma receta con distintos ingredientes de un tiro.” Aplicar: testeá una función con parametrize (3 casos) + una fixture + un mock de una llamada externa. Límites: no mockees lo que estás testeando; mockeá solo los bordes (red, DB, tiempo).


Concepto 8 — Tooling moderno (2026)

Idea núcleo: el stack actual de proyecto Python: uv (entornos/deps rapidísimo), ruff (lint+format en uno), pyproject.toml (config única).

Entender:

uv init && uv add fastapi
uv run ruff check --fix .
uv run pytest

Nota atómica: Front: ¿qué reemplaza ruff? Back: flake8 (lint) + black (format) + isort (imports), en una sola herramienta rápida. Feynman: “Antes tenías 4 cajas de herramientas que discutían entre sí; ahora una sola navaja suiza.” Aplicar: iniciá un proyecto con uv, agregá ruff y pytest, configurá todo en pyproject.toml. Límites: [verificar] versión/estado de uv — ecosistema en movimiento; confirmá antes de fijarlo como estándar.


Checklist de dominio M1

Marcás cuando podés explicar (Feynman) + aplicar cada uno:

Salida verificable del módulo: una CLI con Typer que use generadores + context manager + decorator, tipada, con tests pytest, formateada con ruff, gestionada con uv. Y poder explicar en voz alta por qué cada elección es idiomática.