Guía interna · Ingeniería Mesa247 · agosto 2026

Python para los que vienen de PHP

Para construir m-b-core con criterio: qué cambia respecto a Laravel, qué trampas tiene Python para quien piensa en PHP, y cómo se escribe, se prueba y se mergea código en el nuevo core.

Ya sabes programar. Lo que necesitas no es un curso de Python desde cero, sino un mapa (esto de Laravel es aquello en m-b-core), una lista corta de trampas (donde Python se comporta distinto de lo que tu intuición PHP espera) y el estilo de la casa de m-b-core, que ya está definido y con CI que lo hace cumplir.

Stack Python 3.12 · FastAPI · Pydantic v2 · SQLAlchemy async · Postgres/MySQL legacy · pytest · ruff · mypy Guías hermanas Criterio Mesa247 · React y SSR Documento interno
Empezar

Para quién y qué asume

Para ingenieros del equipo que hoy mantienen l-home-restaurantes, l-parallevar o l-widgetexperiencias y van a empezar a hacer PRs en m-b-core. Asume que sabes Laravel (controladores, Eloquent, FormRequests, middleware, colas) y que leíste la guía Criterio Mesa247: aquí no repetimos las reglas del equipo, las traducimos a Python.

m-b-core no es "el mismo backend en otro lenguaje". Cambian tres cosas de fondo y conviene decirlas de entrada:

  • Es asíncrono. En PHP-FPM cada request es un proceso que puede bloquearse tranquilamente; en m-b-core un solo proceso atiende muchas peticiones a la vez y nunca se bloquea esperando a la base de datos o a la red. Es el cambio de modelo mental más grande (sección async/await).
  • Computa, no materializa. El motor de disponibilidad calcula en vivo sobre una matriz de 15 minutos; no hay tablas precalculadas ni cache que "se olvida". La lógica de negocio son funciones puras con test.
  • La calidad la hace cumplir una máquina. Ruff, mypy, pytest con umbral de cobertura por módulo y CI en cada PR. Lo que en PHP era "buena voluntad", aquí es un check rojo.

De Laravel a m-b-core: el mapa

Lo que ya sabes tiene un equivalente. No es uno a uno (Python no tiene "un framework que lo hace todo"), pero cada concepto tiene su lugar.

En LaravelEn m-b-coreNota
Controlador + rutaservices/api/app/<módulo>/routes.py con APIRouterUna función async def por endpoint; el router se registra en router.py. Delgada: valida, llama, responde.
FormRequest / validate()Modelos Pydantic en schemas.py; Annotated[…, Query(...)] en los parámetrosLa validación es declarativa y genera el OpenAPI (/docs) sola. Un 422 automático si no cumple.
Eloquent / Query BuilderSQL explícito con text() vía legacy_db_read(sql, params); SQLModel/SQLAlchemy para lo propioGuideline 10 del repo: nada de ORM para el motor. Parámetros con :nombre, nunca f-strings.
Service / ActionFunciones en queries.py (I/O) y funciones puras en rules.py/utils.pyMódulos, no clases: en Python un archivo con funciones ya es una unidad de organización.
config/*.php + env()shared/settings.pyUn solo lugar lee variables de entorno. En código, settings.X.
Handler de excepcionesshared/errors/codes.py + error_response(); HTTPException con detail estructuradoCódigos de error como constantes con nombre; el cliente recibe {"success": false, "error": "CODE", "message": …} con el status correcto.
Log::info(...)logging con extra={} (+ Logfire)Guideline 3: niveles con significado; campos estructurados, nunca interpolados en el mensaje.
Carbondatetime + zoneinfo; tz_for() / now_local() en availability_engine/timezones.py, shared/timezone.pyTimezone por restaurante desde Ubigeo.ZonaHoraria, nunca por país (México y Brasil tienen varias).
Jobs / colas / cronservices/worker (Cloud Tasks, Pub/Sub, Cloud Scheduler)Un servicio aparte que expone endpoints /tasks/...; nada de procesos largos dentro de la API.
Middlewareservices/api/app/middleware/ y Depends()Depends es inyección de dependencias por función: sesión de DB, usuario actual, rate limit.
composer.json + vendor/requirements/*.txt con versiones fijas + .venv/Sin lock file en este repo: por eso las versiones van pineadas (fastapi==0.115.6). .venv jamás se commitea.
php artisan …Makefile + python -m … + alembicmake tests, make lint, alembic upgrade head. El workbench de paridad es python3 -m services.api.app.availability_engine.workbench.parity.
PHPUnitpytest (+ pytest-asyncio, unittest.mock)Con TESTING.md: árbol de decisión y regla del "mock boundary". Umbral: motor ≥ 90 %, módulos de lectura ≥ 70 %.
BladeNo haym-b-core es solo API. Las vistas viven en los frontends.
Migracionesalembic/versions/Mismo concepto; se generan con alembic revision --autogenerate y se revisan a mano.

Cambiar la cabeza: cinco ideas antes de la sintaxis

  1. Un archivo es un módulo; una carpeta con __init__.py es un paquete.

    No hay autoload de Composer ni namespace: importas por ruta de módulo (from shared.legacy_db import legacy_db_read) y Python la resuelve desde PYTHONPATH (por eso el Makefile exporta PYTHONPATH := .). Los imports van arriba, absolutos, y ruff los ordena.

  2. Explícito mejor que implícito.

    self se escribe; los tipos se anotan; None se compara con is; el __init__ se llama así. Python premia decir las cosas. Escribe import this en un intérprete y léelo una vez.

  3. Funciones primero, clases cuando hay estado.

    En Laravel todo es una clase porque el framework lo pide. En Python un módulo con funciones (queries.py, rules.py) es la unidad natural. Una clase aparece cuando hay datos con forma (dataclass/Pydantic) o estado que proteger.

  4. Los datos tienen forma declarada.

    El $data[] como bolsa de todo se reemplaza por un dict tipado como mucho, y casi siempre por un modelo Pydantic o una @dataclass. El editor, mypy y el OpenAPI trabajan gratis a partir de eso.

  5. Herramientas, no opiniones.

    El formato lo decide ruff format; los imports, ruff; los tipos, mypy; el estilo de tests, TESTING.md. Nadie discute estilo en un PR: se corre la herramienta.

Python

Sintaxis: PHP → Python, lado a lado

Lo esencial en una tabla, y luego los bloques que más se escriben. Cada fila enlaza al capítulo de El libro de Python (español, corto) por si quieres profundizar.

PHPPythonVer
$x = 1; // comentariox = 1 # comentariosintaxis · variables
null · true · falseNone · True · Falsebooleanos
"Hola " . $nombref"Hola {nombre}"cadenas
[1, 2, 3] (array indexado)[1, 2, 3] (list) · (1, 2, 3) (tuple, inmutable)listas · tuplas
['a' => 1] (array asociativo){"a": 1} (dict) · {1, 2} (set, únicos)diccionarios · sets
isset($a['k']) · $a['k'] ?? $def"k" in a · a.get("k", def)membresía
count($a) · strlen($s)len(a) · len(s)built-ins
foreach ($xs as $x) · foreach ($xs as $k => $v)for x in xs: · for k, v in d.items():for · enumerate · zip
array_map(fn($x) => $x*2, $xs)[x * 2 for x in xs]comprehensions
array_filter($xs, fn($x) => $x > 0)[x for x in xs if x > 0]lambda
in_array($x, $xs)x in xs
explode(",", $s) · implode(",", $xs)s.split(",") · ",".join(xs)
function f($a, $b = 2) { return … }def f(a: int, b: int = 2) -> int: return …funciones · anotaciones
function f(...$args)def f(*args, **kwargs)args/kwargs
$a ? $b : $cb if a else cif
switch / matchmatch x: case 1: … (3.10+) o dict de funcionesmatch
try { } catch (Exception $e) { } finally { }try: … except ValueError as e: … else: … finally: …try/except
throw new DomainException("…")raise ValueError("…") · raise MiError(...) from eexcepciones propias
class A extends B { function __construct() {} }class A(B): def __init__(self): super().__init__()clases · herencia
$this->x · self::CONST · static functionself.x · Clase.CONST · @staticmethod / @classmethodmétodos estáticos
interface · abstracttyping.Protocol · abc.ABCABC · duck typing
private $x · getX()self._x (convención) · @propertyproperty
instanceofisinstance(x, Clase)
namespace App\X; use App\Y;from app.y import Y (archivo = módulo)módulos · paquetes
json_encode($x) · json_decode($s, true)json.dumps(x) · json.loads(s) (m-b-core usa orjson)
var_dump($x); dd($x);print(repr(x)) en local · breakpoint() · y en el código: logging
[$a, $b] = $arr;a, b = arr · first, *rest = arrunpacking
7 / 2 == 3.5 · intdiv(7, 2)7 / 2 == 3.5 · 7 // 2 == 3 · 2 ** 10aritméticos
with(...) / try/finally para cerrar recursoswith open(p) as f: · async with conn:context managers
Generadores (yield) existen pero casi nadie los usaIdiomáticos: yield, generadores, iteradores perezososyield · iteradores
Decoradores no existen (atributos en 8)@router.get(...), @dataclass, @pytest.fixture: funciones que envuelven funcionesdecoradores

Un bloque típico, en los dos idiomas

Laravel
public function show(Request $request, int $localId)
{
    $data = $request->validate(['paxs' => 'required|integer|min:1']);
    $local = Local::find($localId);
    if (!$local) {
        return response()->json(['error' => 'no existe'], 404);
    }
    $slots = $this->disponibilidad->calcular($local, $data['paxs']);
    return response()->json(['slots' => $slots]);
}
m-b-core
@router.get("/locales/{local_id}/slots", response_model=SlotsResponse)
async def get_slots(
    local_id: int,
    paxs: Annotated[int, Query(ge=1)],
) -> SlotsResponse | JSONResponse:
    local = await fetch_local(local_id)                  # queries.py
    if local is None:
        return JSONResponse(404, error_response(LOCAL_NOT_FOUND, f"Local {local_id} no existe"))
    slots = compute_slots(local, paxs)                   # función pura, con tests
    return SlotsResponse(slots=slots)

Fíjate en lo que no hay: $this, llaves, punto y coma, $data. Y en lo que sí: tipos en la firma, validación declarativa en el parámetro, await delante de todo lo que toca I/O, y una función pura para el cálculo.

Las 14 trampas del que viene de PHP

Sitios donde Python hace algo distinto de lo que tu intuición PHP espera. Ninguna es exótica: todas aparecen la primera semana.

1 · La indentación es sintaxis, y los bloques no se cierran.
if x > 0:
    hacer()
    otra()      # dentro del if
tercera()       # fuera del if — sin llaves, sin endif; lo dice la sangría

Cuatro espacios, siempre (ruff format lo impone). Mezclar tabs y espacios es error de sintaxis. Copiar código de un chat con la sangría rota es la fuente número uno de IndentationError.

2 · "0" es verdadero. [], {}, "", 0, None son falsos.
if "0":  print("PHP diría falso; Python dice VERDADERO")   # cadena no vacía = True
if []:   print("no se imprime")                              # lista vacía = False
if 0.0:  print("no se imprime")

En PHP "0" y "0.0" son falsy; en Python solo la cadena vacía lo es. Cuando algo llega como texto desde una query o un query param, compara explícito: if valor == "0". Ver booleanos.

3 · == compara valor y no convierte tipos; is compara identidad. No existe ===.
1 == "1"        # False (PHP: true). No hay coerción de tipos.
1 == 1.0        # True
x is None       # SIEMPRE así para None; nunca x == None
a is b          # ¿son el MISMO objeto? Casi solo se usa con None, True, False

Si en PHP escribías === por costumbre, en Python simplemente escribe ==: ya es estricto. Y is con números o strings da resultados sorpresa; no lo uses para comparar valores. Ver operadores de identidad.

4 · "1" + 1 es un error, no "11" ni 2.
"total: " + 5          # TypeError
f"total: {5}"          # "total: 5"   ← f-strings para todo
"total: " + str(5)     # también vale
int("12") + 1          # 13; int("12a") lanza ValueError (no devuelve 12)

Python no convierte tipos por ti. Es molesto un día y te ahorra bugs para siempre: el "12abc" == 12 de PHP no existe.

5 · d["clave"] que no existe lanza KeyError, no devuelve null.
row = {"Id": 1}
row["Nombre"]              # KeyError
row.get("Nombre")          # None
row.get("Nombre", "")      # "" — el default explícito
lista[10]                  # IndexError si no llega — no None

En PHP un índice inexistente es un notice que todo el mundo ignora; en Python es una excepción. Con filas de la base de datos usa .get() cuando la columna puede faltar, y una KeyError cuando no debería faltar (que explote es lo correcto).

6 · Un array de PHP son cuatro cosas distintas en Python.
[1, 2, 2]              # list: ordenada, mutable, duplicados
(1, 2)                 # tuple: ordenada, INMUTABLE (sirve de clave de dict)
{"a": 1}               # dict: clave→valor, ordenado por inserción
{1, 2}                 # set: únicos, sin orden; `x in s` es O(1)

Elegir bien la estructura es la mitad del diseño. "¿Está este id en la lista?" con 100.000 elementos: set, no list. Ver colecciones.

7 · Los valores por defecto se evalúan UNA vez. Nunca un mutable como default.
def agregar(x, acumulado=[]):     # ¡el mismo [] compartido entre llamadas!
    acumulado.append(x)
    return acumulado
agregar(1); agregar(2)            # → [1, 2]  (no [2])

def agregar(x, acumulado=None):   # así
    if acumulado is None:
        acumulado = []

Ruff lo marca (B006). Es el bug clásico número uno de quien viene de otro lenguaje.

8 · Listas y dicts se pasan por referencia; = no copia.
a = [1, 2]; b = a; b.append(3)     # a también es [1, 2, 3]
b = a.copy()  /  b = list(a)  /  import copy; copy.deepcopy(a)   # copiar de verdad
s = "hola"; s.upper()              # NO cambia s: los strings son inmutables; s = s.upper()

En PHP los arrays se copian al asignar (copy-on-write); en Python compartes el objeto. Ver mutabilidad y paso por valor y referencia.

9 · Las variables de función son locales; no hay $this implícito ni variables mágicas.
contador = 0
def sumar():
    contador += 1        # UnboundLocalError: Python asume que es local
    # `global contador` lo arregla… y es una mala idea: pasa y devuelve valores.

class Motor:
    def calcular(self, x):    # self SIEMPRE explícito
        return self.factor * x

Ver alcance. Estado global mutable = el mismo vicio que en PHP, con otro traje.

10 · except: pelado es el catch (\Throwable $e) {} de Python. Y peor: atrapa Ctrl+C.
try:
    dato = fila["Nombre"]
except KeyError:                       # la excepción CONCRETA que sabes manejar
    dato = None
# nunca:  except: pass   |  except Exception: pass    (ruff E722 / BLE001)
raise UpstreamError("legacy caído") from e     # conservar la causa

Mismo criterio que en la guía PHP: un except reporta y relanza (o traduce), o no existe. En Python es idiomático usar excepciones para el flujo ("pedir perdón, no permiso") pero siempre con la clase específica. Ver excepciones.

11 · / siempre da float; // es la división entera. Los enteros no desbordan.
7 / 2      # 3.5
7 // 2     # 3
-7 // 2    # -4  (redondea hacia abajo, no hacia cero como intdiv)
2 ** 100   # 1267650600228229401496703205376 — sin overflow
12 · datetime.now() "a secas" es naive: no sabe en qué zona está. Y en Cloud Run el reloj está en UTC.
from datetime import datetime, UTC
from zoneinfo import ZoneInfo
datetime.now()                              # naive — prohibido en m-b-core
datetime.now(UTC)                           # aware, UTC
datetime.now(ZoneInfo("America/Lima"))      # aware, Lima
ahora_local = datetime.now(ZoneInfo(local.zona_horaria))   # la del RESTAURANTE (Ubigeo.ZonaHoraria)

Es la misma trampa de las 19:00 de la guía PHP, con otra sintaxis. Comparar un datetime naive con uno aware lanza TypeError: mejor, así se nota. Ver shared/timezone.py.

13 · print() no es un log. Y breakpoint() es tu dd(), pero no se commitea.
import logging
log = logging.getLogger(__name__)
log.info("search.completed", extra={"local_id": local_id, "dates": len(dates), "ms": elapsed})
# nunca: log.info(f"completed local {local_id}")  — el campo va en extra, no interpolado

Guideline 3 del repo: niveles con significado (INFO evento de negocio, DEBUG detalle, WARNING dato raro, ERROR se rompió) y campos estructurados. En local, para mirar algo: breakpoint() te deja en el debugger; ruff falla si queda en el diff (T100).

14 · Cada proyecto tiene su propio Python. No hay "el PHP de la máquina".
python3 -m venv .venv && source .venv/bin/activate     # o `make install`
pip install -r requirements/local.txt
python -m pytest        # el `python -m` garantiza que usas el del venv

.venv/ nunca se commitea (es el vendor/ de aquí, y sí, en el repo PHP vendor/ estaba commiteado: no lo repitas). Si algo "no se encuentra", el 90 % de las veces es que no activaste el venv o falta PYTHONPATH=. (el Makefile lo exporta).

async/await: el cambio de modelo

En PHP-FPM cada request tiene su proceso; si esperas 2 segundos a MySQL, ese proceso espera y los demás siguen. En m-b-core, un solo proceso uvicorn atiende muchas peticiones concurrentes en un event loop: mientras una espera a la base de datos, el loop atiende otras. Eso solo funciona si nadie bloquea el loop.

Cómo lo piensas en PHP
// cada llamada bloquea; no importa, es mi proceso
$local = $api->getLocal($id);        // 300 ms
$horario = $api->getHorario($id);    // 300 ms
sleep(1);
// total ≈ 1.6 s, y los otros requests siguen en sus procesos
Cómo se hace en m-b-core
import asyncio
local, horario = await asyncio.gather(     # las dos a la vez
    fetch_local(id),                        # ← async def, con await adentro
    fetch_horario(id),
)
await asyncio.sleep(1)                      # cede el loop; time.sleep(1) lo BLOQUEA
# total ≈ 1.3 s, y mientras tanto el mismo proceso atendió otros requests

Las reglas del loop

  • Una función que hace I/O es async def y se llama con await. Olvidar el await no da error: te devuelve una corrutina sin ejecutar (mypy y ruff lo avisan; los tests lo pillan).
  • Nunca dentro de una async def: time.sleep, requests.get, drivers de DB síncronos, cálculos de segundos. Para I/O usa las versiones async (httpx.AsyncClient, asyncpg/aiomysql vía legacy_db_read); para CPU pesado, asyncio.to_thread(...) o el worker.
  • Varias llamadas independientes: asyncio.gather(...). Es lo que hace el motor con sus queries.
  • El cliente HTTP saliente se crea una vez y lleva timeout: httpx.AsyncClient(timeout=5.0). Mismo criterio que "Guzzle sin timeout" en la guía PHP.
  • Los tests de funciones async se escriben async def test_x(...); pytest.ini tiene asyncio_mode = auto, no hace falta decorador.

Para entender el mecanismo, no solo las reglas: corrutinas y programación asíncrona en El libro de Python, y luego el capítulo Concurrency and async / await de la documentación de FastAPI (está en español).

Tipos, dataclasses y Pydantic: la forma de los datos

Python es dinámico como PHP, pero m-b-core anota tipos en todas las firmas y mypy los verifica en CI. No es burocracia: es lo que hace que el editor autocomplete, que un refactor no rompa en silencio y que el OpenAPI se genere solo.

La bolsa $data
$data = [];
$data['id'] = $row->Id;
$data['nombre'] = $row->Nombre;
$data['zona'] = $row->ZonaHoraria ?? 'America/Lima';
return $data;      // ¿qué claves tiene? nadie sabe sin leer todo
Datos con forma
from dataclasses import dataclass
from pydantic import BaseModel, Field

@dataclass(frozen=True)              # interno: valor inmutable, barato
class Local:
    id: int
    nombre: str
    zona_horaria: str = "America/Lima"

class LocalDTO(BaseModel):           # borde HTTP: valida y documenta
    id: int
    nombre: str = Field(..., min_length=1)
    zona_horaria: str
  • @dataclass para objetos internos del dominio (el motor usa dataclasses en models.py): sin validación, rápido, frozen=True cuando no debe mutar.
  • Pydantic BaseModel en el borde HTTP (schemas.py): valida la entrada, serializa la salida, genera el esquema. Field(..., ge=0, description="…") documenta y valida en la misma línea.
  • Anotaciones modernas: int | None (no Optional[int]), list[dict[str, Any]], -> None cuando no devuelve. from __future__ import annotations arriba de cada módulo, como ya hace el repo.
  • Constantes con nombre en models.py (guideline 1): ACTIVE_RESERVATION_STATES, MINUTES_PER_HOUR. Un número mágico en un PR es un comentario del revisor asegurado.
  • Enum para conjuntos cerrados (estados, canales) en vez de strings sueltos.

Los vicios de PHP que no se traen (y cómo se llaman en Python)

Los 23 vicios de Criterio Mesa247 tienen su versión Python. Aquí los que más tientan a quien cambia de lenguaje, con la herramienta que los frena en m-b-core.

Vicio en PHPSu disfraz en PythonAsí se hace en m-b-coreQuién lo frena
catch (\Exception $e) {}except Exception: pass · except:Excepción concreta; log.warning(..., extra=…) + raise … from e; en la ruta, error_response(CODE, msg) con el status correctoruff E722/BLE, revisión
echo / dd() / var_dumpprint() · breakpoint() olvidadologging con extra={}; en local, debugger; se borra antes del PRruff T20x/T100
$data[] como bolsadict sin tipo pasando por seis funciones@dataclass / Pydantic; firmas anotadasmypy, revisión
SQL con f-string / whereRaw("… $var")f"SELECT … WHERE id = {local_id}"legacy_db_read("… WHERE Id = :id", {"id": local_id}); los tests pinean las cláusulas y los paramsruff S608, TESTING.md
Carbon::now() sin zona · subHours(5)datetime.now() naive · timedelta(hours=-5)datetime.now(ZoneInfo(local.zona_horaria)); guideline 6: zona por restauranteruff DTZ, tests
env() esparcido por el códigoos.getenv("X") en cualquier móduloSolo shared/settings.py; en código settings.Xrevisión, grep en CI
Controlador de 4.000 líneasroutes.py con la lógica dentro del endpointRuta delgada → queries.py (I/O) → funciones puras (rules.py/utils.py) con testTESTING.md, cobertura ≥ 90 % en el motor
Guzzle sin timeout, un cliente por métodohttpx.get(url) suelto, sin timeout, dentro de async def bloqueandoUn httpx.AsyncClient(timeout=…) compartido; await siemprerevisión, tests con mock
Copiar el método a cinco controladoresCopiar la función a cinco queries.pyFunción en shared/ o helper del módulo; @pytest.mark.parametrize para los casosrevisión
IDs de locales quemados en el códigoif local_id in (11, 873, 874):Flag en la DB (replica_flags.py es el ejemplo: kill-switch leído en runtime) o constante con nombre y motivoguideline 1, revisión
200 con {"success": false}return {"error": "…"} con status 200JSONResponse(status_code=404, content=error_response(LOCAL_NOT_FOUND, …)); códigos en shared/errors/codes.pytests de ruta (assert r.status_code == 404)
Estado global mutableVariables de módulo que se modifican, globalEstado explícito por parámetro; lo que debe vivir entre requests va a Redis/DB (shared/cache.py)revisión
Sin tests, "es chiquito"assert True para subir coberturaTESTING.md §0: "¿qué bug de producción atraparía este test?"; si ninguno, no se escribegate de cobertura + revisión
vendor/ commiteado.venv/ o __pycache__/ commiteado.gitignore ya los excluye; versiones pineadas en requirements/revisión
Merge = deploy sin gatePush a prod sin PRPR a staging → CI (lint, mypy, tests, cobertura) → merge → PR stagingprod.github/workflows/ci.yml, ramas protegidas
m-b-core

Cómo está armado el repo

m-b-core/
├── services/
│   ├── api/app/                 ← la API pública (FastAPI)
│   │   ├── main.py              arranque: carga env, crea la app, registra routers
│   │   ├── router.py            build_api_router(): incluye cada módulo con su prefijo
│   │   ├── availability_engine/ el motor: engine.py, day_matrix.py, rules.py, models.py, queries.py, routes.py…
│   │   ├── cities/  listings/  search/  home/  feed/  auth/  libro/  …   ← un módulo por dominio
│   │   │     ├── routes.py      endpoints (delgados)
│   │   │     ├── queries.py     I/O: SQL con legacy_db_read / Postgres
│   │   │     ├── schemas.py     modelos Pydantic de entrada/salida
│   │   │     ├── utils.py       funciones puras
│   │   │     ├── README.md      qué hace el módulo, contrato, decisiones
│   │   │     └── tests/         al lado del código que prueban
│   │   ├── conftest.py          fixtures compartidas (legacy_read_mock, …)
│   │   └── TESTING.md           el estándar de tests: léelo antes de escribir uno
│   ├── bridge/                  réplica legacy MySQL → Postgres (polling)
│   └── worker/                  tareas: Cloud Tasks / Pub/Sub / Scheduler
├── shared/                      lo común: settings.py, legacy_db/, errors/, cache.py, timezone.py, decoradores/
├── alembic/                     migraciones de Postgres
├── requirements/                dependencias pineadas por entorno (local.txt, …)
├── docs/                        ADR, operaciones, PRD, infra
├── env/                         plantillas de entorno (nunca secretos reales)
├── Makefile · docker-compose.yml · pyproject.toml (ruff, mypy, coverage) · pytest.ini
└── CLAUDE.md                    las guidelines del repo: léelo primero, siempre
Los diez mandamientos ya están escritos

CLAUDE.md del repo tiene las 10 guidelines: sin números mágicos, reglas como funciones puras, logging estructurado, tests primero y paridad después, paridad con el comportamiento correcto del legacy (no con sus bugs), timezone por restaurante, calendario vs horas, proyección single-bin vs service-window, commits atómicos type(scope): description, y no agregar dependencias sin necesidad. Esta guía no las reemplaza: te prepara para cumplirlas.

Levantarlo y probarlo

Todo local corre en Docker Compose (Postgres + Redis + los tres servicios). Antes de tocar nada, léete docs/operations/environments.md y la sección m-b-core de la guía de revisión local: local apunta a staging o a un stub, nunca a producción.

git clone git@github.com:Mesa247/m-b-core.git && cd m-b-core
docker compose up -d postgres redis
docker compose run --rm api alembic upgrade head
docker compose up api bridge worker            # api :8888 · bridge :8889 · worker :8890
# tests (dentro del contenedor, con el mismo Python que prod)
docker compose exec -T api python3 -m pytest /app/services/api/tests/ -v
# lint + formato + tipos (lo mismo que corre la CI)
ruff check . && ruff format --check .
mypy services/api/app/availability_engine/ --ignore-missing-imports --explicit-package-bases
# un restaurante, en vivo
curl "http://localhost:8888/v2/search?local_id=11&paxs=2&start_date=2026-04-18&end_date=2026-04-20&type_reservation=widget"
curl "http://localhost:8888/v2/explain?local_id=11&date=2026-04-18&paxs=2"     # por qué una fecha está bloqueada

Sin Docker (para iterar rápido): make install crea el .venv e instala requirements/local.txt; make run-api levanta uvicorn con recarga; PYTHONPATH=. python -m pytest corre los tests. El Makefile exporta PYTHONPATH por ti.

Tres cosas que rompen la primera vez

(1) ModuleNotFoundError: services → falta PYTHONPATH=. o no estás en la raíz. (2) Los tests marcados e2e pegan a servicios reales; están excluidos por defecto (-m "not e2e"), no los corras contra prod. (3) Cambiaste una query y "el test pasa igual": estás mockeando un nivel demasiado arriba (ver cómo se testea aquí).

Una feature de punta a punta, al estilo de la casa

Supón que hay que exponer GET /v1/locales/{local_id}/zona-horaria que devuelve la zona IANA del restaurante (con fallback por país). Así se hace un módulo mínimo siguiendo exactamente el patrón de cities/. Léelo con la tabla del mapa al lado.

services/api/app/locales/schemas.py
"""Request/response models for /v1/locales (OpenAPI)."""

from __future__ import annotations

from pydantic import BaseModel, Field


class ZonaHorariaDTO(BaseModel):
    local_id: int = Field(..., ge=1)
    zona_horaria: str = Field(..., min_length=1, description="IANA tz, p.ej. 'America/Lima'")
    origen: str = Field(..., description="'ubigeo' si vino de Ubigeo.ZonaHoraria, 'pais' si fue fallback")
services/api/app/locales/queries.py
"""I/O: lee del legacy lo que la ruta necesita. Sin lógica de negocio."""

from __future__ import annotations

from typing import Any

from shared.legacy_db import legacy_db_read


async def fetch_local_tz(local_id: int) -> dict[str, Any] | None:
    """Zona horaria del local desde Ubigeo, y país como fallback. None si no existe."""
    rows = await legacy_db_read(
        """
            SELECT l.Id AS local_id, l.Pais AS pais, u.ZonaHoraria AS zona_horaria
            FROM Local l
            LEFT JOIN Ubigeo u ON u.Pais = l.Pais AND u.Depa = l.Depa AND u.EstadoId = 1
            WHERE l.Id = :local_id
            LIMIT 1
        """,
        {"local_id": local_id},          # parámetro nombrado: nunca f-string
    )
    return rows[0] if rows else None
services/api/app/locales/utils.py
"""Funciones puras: fáciles de testear, sin I/O."""

from __future__ import annotations

from typing import Any

from services.api.app.availability_engine.timezones import tz_for   # ya existe: Ubigeo primero, país después


def resolve_tz(row: dict[str, Any]) -> tuple[str, str]:
    """Devuelve (zona, origen). Guideline 6: primero Ubigeo.ZonaHoraria, luego país."""
    zona = tz_for(row.get("zona_horaria"), row.get("pais"))
    origen = "ubigeo" if row.get("zona_horaria") else "pais"
    return zona, origen
services/api/app/locales/routes.py
"""GET /v1/locales/{local_id}/zona-horaria."""

from __future__ import annotations

from fastapi import APIRouter
from fastapi.responses import JSONResponse

from shared.errors import error_response
from shared.errors.codes import LOCAL_NOT_FOUND

from .queries import fetch_local_tz
from .schemas import ZonaHorariaDTO
from .utils import resolve_tz

router = APIRouter(tags=["locales"])


@router.get("/locales/{local_id}/zona-horaria", response_model=ZonaHorariaDTO)
async def get_zona_horaria(local_id: int) -> ZonaHorariaDTO | JSONResponse:
    row = await fetch_local_tz(local_id)
    if row is None:
        return JSONResponse(
            status_code=404,
            content=error_response(LOCAL_NOT_FOUND, f"Local {local_id} no existe"),
        )
    zona, origen = resolve_tz(row)
    return ZonaHorariaDTO(local_id=local_id, zona_horaria=zona, origen=origen)

Falta: agregar LOCAL_NOT_FOUND a shared/errors/codes.py (junto a los otros 404), y una línea en router.py: api.include_router(locales_router). Y el README.md del módulo con el contrato.

Los tests, según el árbol de decisión de TESTING.md

services/api/app/locales/tests/test_utils.py — función pura → parametrizar bordes
import pytest

from services.api.app.locales.utils import resolve_tz


@pytest.mark.parametrize(
    "row, esperado",
    [
        ({"pais": "PE", "zona_horaria": "America/Lima"}, ("America/Lima", "ubigeo")),
        ({"pais": "CL", "zona_horaria": None}, ("America/Santiago", "pais")),
        ({"pais": "MX", "zona_horaria": ""}, ("America/Mexico_City", "pais")),   # cadena vacía = falsy
    ],
)
def test_resolve_tz(row, esperado):
    assert resolve_tz(row) == esperado
services/api/app/locales/tests/test_queries.py — queries.py → patch en el binding local
from services.api.app.locales import queries as q


async def test_fetch_local_tz_pasa_el_id_como_parametro(legacy_read_mock, monkeypatch):
    legacy_read_mock.return_value = [{"local_id": 11, "pais": "PE", "zona_horaria": "America/Lima"}]
    monkeypatch.setattr(q, "legacy_db_read", legacy_read_mock)      # un nivel abajo, no la ruta

    row = await q.fetch_local_tz(11)

    assert row["zona_horaria"] == "America/Lima"
    assert legacy_read_mock.last_params == {"local_id": 11}
    assert "WHERE l.Id = :local_id" in legacy_read_mock.last_sql     # pinear la cláusula, no el SQL entero


async def test_fetch_local_tz_devuelve_none_si_no_hay_filas(legacy_read_mock, monkeypatch):
    legacy_read_mock.return_value = []
    monkeypatch.setattr(q, "legacy_db_read", legacy_read_mock)
    assert await q.fetch_local_tz(999) is None
services/api/app/locales/tests/test_routes.py — ruta → TestClient + patch de la función que importa la ruta
from unittest.mock import AsyncMock, patch

from fastapi.testclient import TestClient

from services.api.app.main import app

client = TestClient(app)
_MOCK = "services.api.app.locales.routes.fetch_local_tz"


def test_200_con_zona_de_ubigeo():
    with patch(_MOCK, new=AsyncMock(return_value={"local_id": 11, "pais": "PE", "zona_horaria": "America/Lima"})):
        r = client.get("/v1/locales/11/zona-horaria")
    assert r.status_code == 200
    assert r.json() == {"local_id": 11, "zona_horaria": "America/Lima", "origen": "ubigeo"}


def test_404_si_el_local_no_existe():
    with patch(_MOCK, new=AsyncMock(return_value=None)):
        r = client.get("/v1/locales/999/zona-horaria")
    assert r.status_code == 404
    assert r.json()["error"] == "LOCAL_NOT_FOUND"


def test_422_si_el_id_no_es_entero():
    r = client.get("/v1/locales/abc/zona-horaria")     # lo valida FastAPI, sin mock
    assert r.status_code == 422

Fíjate en las tres capas y sus tres tipos de test: la función pura se parametriza sin mocks; la query se prueba mockeando legacy_db_read en el módulo y pineando cláusulas y parámetros; la ruta se prueba con TestClient mockeando la función que la ruta importa. Ninguno mockea la función que está probando. Eso es la "regla del mock boundary".

ruff check services/api/app/locales && ruff format services/api/app/locales
PYTHONPATH=. python -m pytest services/api/app/locales -q
git switch -c feat/locales-zona-horaria
git commit -am "feat(locales): endpoint zona-horaria con fallback por país"
gh pr create --base staging --fill

Cómo se testea aquí (resumen de TESTING.md)

  • Principio 0: la cobertura es consecuencia, no meta. Antes de escribir un test: "¿qué bug de producción atraparía si se rompiera?". Si ninguno, no se escribe. Mejor 88 % genuino que 95 % inflado.
  • Árbol de decisión: función pura → parametrizar bordes sin mocks; *queries.py → mockear legacy_db_read en el binding local del módulo; orquestador → AsyncMock de cada subfunción; ruta → TestClient + patch de lo que la ruta importa; LLM/HTTP externo → patch del cliente.
  • Mock boundary: mockea el nivel más bajo de la dependencia, nunca la función bajo prueba. Mockear un nivel arriba deja la query sin cubrir y "pasa" aunque el SQL esté roto.
  • Asserts sobre SQL: pinear cláusulas que llevan contrato ("Pais = :country_code" in sql) y los params; nunca igualdad del string entero.
  • Marcas: unit, integration, e2e (excluidos por defecto). asyncio_mode = auto: los tests async no llevan decorador.
  • Gates de CI: cobertura global ≥ 70 %, motor de disponibilidad ≥ 90 %, módulos de lectura ≥ 70 % (scripts/check_coverage.py).
  • Paridad: para el motor, después de los tests, el workbench compara contra prod. Criterio: 0 falsos negativos (m-b-core bloquea algo que legacy muestra disponible).

Checklist de PR en m-b-core

Además del checklist general de Criterio Mesa247, esto es lo específico de Python y de este repo.

  • ruff check . y ruff format --check . limpios; mypy del motor sin errores nuevos.
  • Todas las firmas nuevas anotadas (-> incluido); sin Any gratuito.
  • Todo I/O es async def + await; nada bloqueante en el loop (time.sleep, requests, drivers síncronos).
  • SQL con parámetros nombrados; los tests pinean cláusulas y params.
  • Fechas con zona explícita del restaurante; ningún datetime.now() naive.
  • Errores con error_response + código en shared/errors/codes.py y el status HTTP correcto; ningún except: pelado.
  • Logs con extra={} y nivel correcto; ningún print/breakpoint.
  • Constantes en models.py; ningún número o string mágico nuevo.
  • Tests según el árbol de TESTING.md, mockeando en el boundary correcto; los gates de cobertura pasan.
  • Sin dependencias nuevas (o justificadas en el PR y pineadas en requirements/).
  • Commit type(scope): description, PR a staging; el README.md del módulo actualizado si cambia el contrato.
  • Puedo explicar cada línea, incluidas las que escribió la IA.
Prácticas

Ocho prácticas: el mismo repo, construido paso a paso

La guía de arriba es el mapa; esto es el terreno. Ocho prácticas guiadas que construyen un solo mini-repo (mesa-core-practicas) que calca la estructura, las convenciones y las herramientas reales de m-b-core: mismo pyproject.toml, mismo pytest.ini, mismos error_response, legacy_db_read y LegacyReadMock, mismo estilo de módulo que cities/. Cada una agrega una capa y termina con algo que corre.

Todo el código y todas las salidas de "Deberías ver" se ejecutaron de verdad al escribirlas; lo que no se pudo ejecutar (comandos de gcloud, Cloud Tasks) está marcado en el pie de cada página. Hazlas en orden: cada una parte del repo que dejó la anterior.

1 · Entorno, funciones puras y tests

El repo, el venv, requirements pineados, el Makefile con PYTHONPATH; un motor mínimo de disponibilidad: constantes, dataclasses, matriz con numpy y reglas como funciones puras con tests parametrizados. ruff y mypy en verde.

2 · async/await de verdad

Un cliente httpx con timeout, gather con Semaphore, to_thread para lo pesado, y los tres errores clásicos medidos: olvidar await, time.sleep bloqueando el loop y requests síncrono. Tests con la red simulada.

3 · Un módulo como cities/

main.py, router.py, schemas.py con Pydantic, utils.py puro, routes.py delgado con error_response y los códigos correctos; /health, /docs y tests con TestClient (200/404/422).

4 · SQL con parámetros y el mock boundary

legacy_db_read(sql, params) de verdad, la inyección demostrada sobre tu propia base, y el patrón de tests de la casa: patchear en el binding local del módulo y pinear cláusulas y parámetros, no el SQL entero.

5 · Postgres propio con SQLModel y alembic

Compose con Postgres 16, modelos, migraciones generadas y revisadas, upgrade/downgrade, transacciones que no dejan la base a medias, y la máscara de credenciales bien hecha (el bug real de m-b-core como trampa).

6 · Fechas por restaurante

tz_for y now_local, aware vs naive, la trampa de las 19:00 de Lima en un test, Santiago con horario de verano y por qué la zona sale de Ubigeo.ZonaHoraria y no del país. Ruff DTZ activado.

7 · Errores, logging y tareas idempotentes

Excepciones de dominio mapeadas una vez a HTTP, logs con extra={} en JSON y sin PII, un worker con secreto comparado en tiempo constante, idempotencia por tabla y reintentos con backoff.

8 · Docker, pipeline y tu primer PR

Dockerfile multi-stage con los tests dentro del build y USER app, CI con ruff + mypy + cobertura, el pipeline comentado línea a línea (incluida la causa raíz del conector perdido), y cómo se hace el primer PR real en m-b-core.

Cómo hacerlas

Una práctica por sesión de dos horas, en orden. Escribe los archivos tú (no copies bloques enteros); corre cada comando y compara con lo que "deberías ver". Cuando algo difiera, para ahí: la sección "Si algo falla" de cada página tiene los errores reales que aparecieron al escribirla.

Ruta

Tres semanas, de PHP a un PR mergeado

Semana 1 · Python de verdad

Un venv en tu máquina, VS Code con la extensión Python (y el debugger, no print). Lee en El libro de Python: introducción, sintaxis, tipos, listas, diccionarios, sets, funciones, args/kwargs, excepciones, clases, decoradores, context managers, yield, módulos, mutabilidad, testing, código pythónico, errores comunes. Ejercicio: reescribe en Python una función que hayas escrito en PHP este mes, con sus tests en pytest.

Semana 2 · El stack

Tutorial oficial de FastAPI (en español) hasta "Dependencies"; Pydantic (modelos, Field, validadores); corrutinas + el capítulo async de FastAPI; pytest con parametrize, fixtures y unittest.mock. Proyecto: una API de 3 endpoints con SQL parametrizado sobre SQLite, tests con mock del acceso a datos, ruff y mypy en verde, Dockerfile. (Si quieres el paso a paso, los proyectos 3–4 del Cuaderno son exactamente eso.)

Semana 3 · Dentro de m-b-core

Levanta el repo, lee CLAUDE.md, TESTING.md y el README.md de cities/ y del motor. Primer PR: un test que falte en un módulo de lectura (el gate de cobertura te dice dónde). Segundo PR: un endpoint pequeño de un módulo existente o el ejemplo de la feature de punta a punta. Revisión con alguien del core; retro de 15 minutos: qué del mapa te confundió, para mejorar esta guía.

Con Claude en m-b-core

El repo ya tiene CLAUDE.md con las guidelines: Claude Code arranca con el criterio de la casa. Aplican las reglas de Criterio Mesa247 (pedidos del tamaño de un commit, leer todo el diff, test primero, sin dependencias nuevas sin preguntar, sin secretos en el prompt) y, para el que está aprendiendo Python, tres más:

  1. Pide la traducción, no la solución.

    "Así lo haría en Laravel: [código]. ¿Cómo se hace en m-b-core siguiendo cities/ como modelo? Explícame cada diferencia." Aprendes el mapa con tu propio código.

  2. Que te explique el traceback antes de arreglarlo.

    Los errores de Python (TypeError: object NoneType…, RuntimeWarning: coroutine was never awaited) son muy informativos si los lees de abajo hacia arriba. "Este es el traceback; ¿qué línea es la causa y por qué?" antes de "arréglalo".

  3. Que te haga el examen.

    Después de cada sección de esta guía: "hazme cinco preguntas de criterio sobre [async / tipos / tests en m-b-core] y corrige mis respuestas con dureza".

Así noAgrega un endpoint que devuelva la zona horaria del local.Va a inventar el módulo desde cero, quizá con ORM, sin seguir cities/, y sin tests del boundary correcto.
Así síCrea el módulo locales/ calcando la estructura de cities/ (schemas, queries con legacy_db_read y parámetros nombrados, utils puros, routes con error_response). Primero solo utils.resolve_tz con sus tests parametrizados; no toques nada más hasta que yo lo revise.Modelo a seguir explícito, alcance de un commit, tú controlas el siguiente paso.

Recursos, pocos y elegidos