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)

Profundizando — qué es un thread realmente (por debajo del GIL)

Un thread no es un concepto de Python — es un mecanismo del sistema operativo: una secuencia de ejecución con su propio stack, compartiendo memoria heap con los demás threads del proceso. El scheduler del SO decide cuándo corre cada thread, de forma preemptiva — le puede quitar el CPU a un thread en cualquier momento (cada pocos milisegundos) sin pedirle permiso. Ese cambio de un thread a otro (context switch) tiene costo real: guardar/restaurar registros del CPU, stack pointer, program counter.

En Python, los threads sí son threads reales del SO — el scheduler los intercala igual que en cualquier lenguaje. Lo que agrega CPython es una capa extra encima: cada thread necesita tomar el GIL antes de ejecutar bytecode. Por eso hay dos niveles de “turno”: el del SO (quién tiene el CPU) y el del GIL (quién puede tocar el intérprete). Aunque el SO reparta 8 cores entre 8 threads, el GIL sigue dejando avanzar bytecode Python de a uno.

El problema real de compartir memoria — race condition:

saldo = 100

def retirar(monto):
    global saldo
    if saldo >= monto:        # thread A lee saldo=100
        # <-- el scheduler puede interrumpir justo acá
        saldo -= monto         # thread B también leyó saldo=100 antes de que A restara

Dos threads pueden leer saldo=100 antes de que ninguno reste — ambos retiran creyendo que hay fondos. El resultado depende del orden exacto en que el scheduler los intercaló: impredecible. Se arregla con un lock (threading.Lock): solo un thread entra a esa sección a la vez, los demás esperan.

sequenceDiagram
    participant SO as Scheduler SO
    participant A as Thread A
    participant B as Thread B
    SO->>A: corre (tiene el CPU)
    A->>A: lee saldo=100
    SO-->>A: context switch (interrumpe)
    SO->>B: corre
    B->>B: lee saldo=100 (todavía no restó A)
    B->>B: saldo -= monto
    SO-->>B: context switch
    SO->>A: retoma
    A->>A: saldo -= monto (sobre un saldo ya viejo)

Fijar (capa 2)

Nota atómica:

Nota atómica 2:

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). Y el race condition es como dos cajeros de un banco mirando el mismo saldo en la pantalla antes de que ninguno actualice el sistema — ambos ven ‘$100 disponibles’ y ambos aprueban el retiro.”

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. Después reproducí el race condition de retirar() con 2 threads y 1000 iteraciones cada uno — contá cuántas veces el saldo final no cuadra. Arreglalo con threading.Lock().

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

Profundizando — la pirámide de testing (qué capa testea qué, y qué mockear):

Cuatro capas, de más rápida/aislada a más lenta/realista:

Regla de mockeo: mockeá lo que no es tuyo (red, DB externa, reloj, terceros) — nunca lo que SÍ es tuyo y estás probando, o el test deja de probar algo real.

TDD (red-green-refactor), con matiz: test que falla (red) → código mínimo que lo pasa (green) → mejorás diseño sin romper el test (refactor). Útil cuando el comportamiento esperado es claro de entrada; no es dogma — en exploración donde el diseño no está claro todavía, tests después de estabilizar la forma tiene más sentido.

Coverage, la trampa: 100% de cobertura solo significa que cada línea se ejecutó una vez, no que se validó el comportamiento correcto bajo casos edge. Un test sin assert real cuenta como cobertura y no prueba nada.

flowchart TD
    U[Unit — todo mockeado, ms] --> I[Integration — tus piezas + DB real]
    I --> C[Contract — verifica contrato externo]
    C --> E[E2E — sistema completo, lento y frágil]

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.

Nota atómica 2: Front: ¿por qué un integration test que mockea tu propia DB no sirve para lo que promete? Back: porque el punto es verificar que TUS piezas funcionan juntas de verdad — si mockeás la DB, volviste a tener (a lo sumo) un unit test disfrazado.

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. La pirámide de testing es como probar un auto: unit es probar cada pieza suelta en el banco, integration es el motor completo armado, e2e es sacarlo a la calle — necesitás las tres, pero no en la misma proporción.” Aplicar: testeá una función con parametrize (3 casos) + una fixture + un mock de una llamada externa. Después escribí 1 integration test real (testcontainers o DB de prueba) para la misma funcionalidad y notá la diferencia de qué prueba cada uno. Límites: no mockees lo que estás testeando; mockeá solo los bordes (red, DB, tiempo). Demasiados tests E2E vuelven el CI lento y frágil — la pirámide tiene esa forma (muchos unit, pocos e2e) por algo.


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.


Concepto 9 — Estructuras de datos: dict vs list (por qué cada una)

Idea núcleo: una lista busca recorriendo (O(n)); un diccionario busca calculando una posición (O(1)). La diferencia de mecanismo, no solo de sintaxis, es lo que separa cuándo usar cada una.

Entender (capa 1)

Texto: una list guarda elementos en secuencia; buscar uno (x in lista) implica recorrerla de a uno hasta encontrarlo — O(n). Un dict usa una tabla hash: al guardar dic["clave"] = valor, Python aplica una función hash a "clave" que da un número, y ese número indica directamente la posición de memoria. Buscar recalcula el mismo hash y va directo — O(1) amortizado, casi sin importar el tamaño.

Visual:

flowchart LR
    subgraph Lista["list — O(n)"]
        Q1[buscar 'x'] --> R1[recorrer 0,1,2...] --> R2[hasta encontrar o terminar]
    end
    subgraph Dict["dict — O(1)"]
        Q2[buscar 'x'] --> H[hash('x') = posición] --> D[ir directo a esa posición]
    end

Video: Time complexities of Python’s built-in data types — mCoding [verificar]

Ejemplo:

lista = [f"item{i}" for i in range(1_000_000)]
dic = {f"item{i}": i for i in range(1_000_000)}

"item999999" in lista   # recorre hasta 1 millón de elementos → lento
"item999999" in dic     # calcula 1 hash, va directo → rápido, ~constante

Colisiones (por qué no es O(1) “puro”): si dos claves distintas producen el mismo hash, Python resuelve internamente (probing) — por eso se dice O(1) amortizado, no garantizado en el peor caso teórico, pero efectivamente constante en la práctica.

Fijar (capa 2)

Nota atómica:

Feynman: “Una lista es buscar un libro en un estante sin orden: los revisás uno por uno. Un diccionario es una biblioteca con ficha catalográfica: calculás el código y vas directo al estante exacto.”

Aplicar (capa 3)

Medí (time) "x" in lista vs "x" in dic/set sobre 1 millón de elementos, buscando el último. Compará los tiempos. Después implementá un contador de frecuencia de palabras con dict y explicá por qué una list de tuplas sería más lenta para ese caso.

Límites


Concepto 10 — Debugging bajo presión: el formato “Bug Bash” de entrevista

Idea núcleo: varias empresas (Stripe, Retool) usan una ronda donde te dan un repo AJENO con un bug/test roto, 45-60 min. No evalúan si llegás al fix completo — evalúan cómo razonás código que no escribiste vos.

Entender (capa 1)

Texto: el formato reportado por candidatos: repo real (a veces una librería OSS con versión fijada) o codebase propio de la empresa con bugs sembrados, elegís lenguaje/IDE, se espera que preguntes antes de zambullirte. Una hipótesis bien articulada aunque el fix quede a medias pesa más que un fix completo sin explicar el razonamiento — es lo opuesto a un take-home, donde solo importa el resultado final.

La secuencia que separa señal de ruido (aplica a cualquier debugging bajo tiempo, no solo entrevista):

  1. Reproducir primero — nunca leas código sin poder disparar el fallo de forma confiable. Si no reproducís, estás adivinando.
  2. Aislar / bisectar — reducí la superficie: comentar capas, git bisect si es una regresión, binary search sobre el pipeline hasta encontrar el punto exacto donde el comportamiento cambia.
  3. Narrar la hipótesis en voz alta y probar la más barata primero — no la más probable, la que menos cuesta descartar.
  4. Preguntar antes de zambullirse: ¿cuál es el comportamiento esperado vs el actual? ¿es regresión de un cambio reciente o siempre estuvo roto? ¿qué ya se descartó? ¿puedo correr/modificar los tests?

Visual:

flowchart TD
    START[bug reportado] --> Q[preguntar: esperado vs actual,<br/>regresión o siempre roto, qué se descartó]
    Q --> R[reproducir el fallo<br/>de forma confiable]
    R --> I[aislar / bisectar<br/>reducir superficie]
    I --> H{hipótesis}
    H --> T[probar la MÁS BARATA primero]
    T -->|confirma| FIX[fix + explicar el porqué]
    T -->|descarta| H

Fijar (capa 2)

Nota atómica:

Feynman: “Es como un médico con un paciente nuevo: no empieza a operar a ciegas. Pregunta síntomas, descarta lo más barato de chequear primero (temperatura antes que una resonancia), y dice en voz alta qué está pensando y por qué — aunque todavía no tenga el diagnóstico final.”

Aplicar (capa 3)

Elegí un repo open source que no conozcas, clonalo, y buscá (o provocá vos mismo) un test que falle. Cronometrate 45 min: reproducí, aislá con git bisect si aplica, narrá en voz alta (grabate) cada hipótesis antes de probarla. Al final, revisá la grabación — ¿cuánto tiempo pasó entre “tengo una idea” y “la probé”, sin decir nada en el medio?

Límites


Concepto 11 — Algoritmia clásica (DSA): eficiencia mecánica, no solo que funcione

Idea núcleo: “que funcione” y “que sea eficiente” son dos barras distintas — un senior sabe cuándo la diferencia entre O(n) y O(n²) importa de verdad, y conoce las estructuras que resuelven cada patrón sin reinventarlas.

Entender (capa 1)

Texto — las estructuras que resuelven el 80% de los casos de entrevista/producción:

Big-O de las operaciones que realmente se preguntan:

EstructuraAccesoInsertarBuscar
list (final)O(1)O(1) amortizadoO(n)
list (principio)O(1)O(n)O(n)
dict/setO(1) amortizadoO(1) amortizado
deque (ambos extremos)O(1)O(1)O(n)
heapq (heap)O(1) el mínimoO(log n)O(n) (no está ordenado)
lista ordenada + bisectO(1)O(n) (mover elementos)O(log n)

Visual — por qué anidar loops es la trampa más común:

flowchart LR
    subgraph Malo["O(n²) — loop dentro de loop"]
        A1[para cada elemento] --> A2[recorrer TODOS los demás] --> A3[comparar]
    end
    subgraph Bueno["O(n) — two pointers / set"]
        B1[un solo recorrido] --> B2[estructura auxiliar dict/set/deque] --> B3[respuesta directa]
    end

Video: Big O Notation — Full Course — freeCodeCamp.org [verificar]

Ejemplo (el mismo problema, O(n²) vs O(n)):

# ❌ O(n²): para cada número, recorre TODOS los demás buscando el par
def dos_suman(nums, objetivo):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):
            if nums[i] + nums[j] == objetivo:
                return (i, j)

# ✅ O(n): un solo recorrido, dict guarda "qué falta" para cada número visto
def dos_suman_bien(nums, objetivo):
    visto = {}                              # valor -> índice
    for i, n in enumerate(nums):
        falta = objetivo - n
        if falta in visto:                  # O(1) — la razón de usar dict
            return (visto[falta], i)
        visto[n] = i

Fijar (capa 2)

Nota atómica:

Feynman: “Un heap es una fila de emergencias en un hospital: no está ordenada de punta a punta, pero siempre sabés instantáneamente quién es el más urgente. Un deque es una fila de gente donde podés atender o sumar gente por cualquiera de las dos puntas sin reacomodar a todo el mundo — una list normal es una fila donde, si sacás al primero, todos tienen que dar un paso adelante.”

Aplicar (capa 3)

Resolvé “dos números que suman un objetivo” primero con doble loop (O(n²)) y medí el tiempo con 10.000 elementos; después con dict (O(n)) y medí de nuevo. Por separado, implementá “los 3 elementos más grandes de una lista sin ordenarla completa” usando heapq.nlargest.

Límites


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.