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.
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 Laravel | En m-b-core | Nota |
|---|---|---|
| Controlador + ruta | services/api/app/<módulo>/routes.py con APIRouter | Una 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ámetros | La validación es declarativa y genera el OpenAPI (/docs) sola. Un 422 automático si no cumple. |
| Eloquent / Query Builder | SQL explícito con text() vía legacy_db_read(sql, params); SQLModel/SQLAlchemy para lo propio | Guideline 10 del repo: nada de ORM para el motor. Parámetros con :nombre, nunca f-strings. |
| Service / Action | Funciones en queries.py (I/O) y funciones puras en rules.py/utils.py | Módulos, no clases: en Python un archivo con funciones ya es una unidad de organización. |
config/*.php + env() | shared/settings.py | Un solo lugar lee variables de entorno. En código, settings.X. |
Handler de excepciones | shared/errors/codes.py + error_response(); HTTPException con detail estructurado | Có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. |
| Carbon | datetime + zoneinfo; tz_for() / now_local() en availability_engine/timezones.py, shared/timezone.py | Timezone por restaurante desde Ubigeo.ZonaHoraria, nunca por país (México y Brasil tienen varias). |
| Jobs / colas / cron | services/worker (Cloud Tasks, Pub/Sub, Cloud Scheduler) | Un servicio aparte que expone endpoints /tasks/...; nada de procesos largos dentro de la API. |
| Middleware | services/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 … + alembic | make tests, make lint, alembic upgrade head. El workbench de paridad es python3 -m services.api.app.availability_engine.workbench.parity. |
| PHPUnit | pytest (+ pytest-asyncio, unittest.mock) | Con TESTING.md: árbol de decisión y regla del "mock boundary". Umbral: motor ≥ 90 %, módulos de lectura ≥ 70 %. |
| Blade | No hay | m-b-core es solo API. Las vistas viven en los frontends. |
| Migraciones | alembic/versions/ | Mismo concepto; se generan con alembic revision --autogenerate y se revisan a mano. |
Cambiar la cabeza: cinco ideas antes de la sintaxis
- Un archivo es un módulo; una carpeta con
__init__.pyes 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 desdePYTHONPATH(por eso elMakefileexportaPYTHONPATH := .). Los imports van arriba, absolutos, y ruff los ordena. - Explícito mejor que implícito.
selfse escribe; los tipos se anotan;Nonese compara conis; el__init__se llama así. Python premia decir las cosas. Escribeimport thisen un intérprete y léelo una vez. - 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. - Los datos tienen forma declarada.
El
$data[]como bolsa de todo se reemplaza por undicttipado como mucho, y casi siempre por un modelo Pydantic o una@dataclass. El editor, mypy y el OpenAPI trabajan gratis a partir de eso. - 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.
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.
| PHP | Python | Ver |
|---|---|---|
$x = 1; // comentario | x = 1 # comentario | sintaxis · variables |
null · true · false | None · True · False | booleanos |
"Hola " . $nombre | f"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 : $c | b if a else c | if |
switch / match | match x: case 1: … (3.10+) o dict de funciones | match |
try { } catch (Exception $e) { } finally { } | try: … except ValueError as e: … else: … finally: … | try/except |
throw new DomainException("…") | raise ValueError("…") · raise MiError(...) from e | excepciones propias |
class A extends B { function __construct() {} } | class A(B): def __init__(self): super().__init__() | clases · herencia |
$this->x · self::CONST · static function | self.x · Clase.CONST · @staticmethod / @classmethod | métodos estáticos |
interface · abstract | typing.Protocol · abc.ABC | ABC · duck typing |
private $x · getX() | self._x (convención) · @property | property |
instanceof | isinstance(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 = arr | unpacking |
7 / 2 == 3.5 · intdiv(7, 2) | 7 / 2 == 3.5 · 7 // 2 == 3 · 2 ** 10 | aritméticos |
with(...) / try/finally para cerrar recursos | with open(p) as f: · async with conn: | context managers |
Generadores (yield) existen pero casi nadie los usa | Idiomáticos: yield, generadores, iteradores perezosos | yield · iteradores |
| Decoradores no existen (atributos en 8) | @router.get(...), @dataclass, @pytest.fixture: funciones que envuelven funciones | decoradores |
Un bloque típico, en los dos idiomas
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]);
}@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.
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.
"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.
== 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.
"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.
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).
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.
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.
= 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.
$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.
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.
/ 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 overflowdatetime.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.
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).
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.
// 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 procesosimport 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 requestsLas reglas del loop
- Una función que hace I/O es
async defy se llama conawait. Olvidar elawaitno 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/aiomysqlvíalegacy_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.initieneasyncio_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.
$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 todofrom 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@dataclasspara objetos internos del dominio (el motor usa dataclasses enmodels.py): sin validación, rápido,frozen=Truecuando no debe mutar.- Pydantic
BaseModelen 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(noOptional[int]),list[dict[str, Any]],-> Nonecuando no devuelve.from __future__ import annotationsarriba 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. Enumpara 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 PHP | Su disfraz en Python | Así se hace en m-b-core | Quié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 correcto | ruff E722/BLE, revisión |
echo / dd() / var_dump | print() · breakpoint() olvidado | logging con extra={}; en local, debugger; se borra antes del PR | ruff T20x/T100 |
$data[] como bolsa | dict sin tipo pasando por seis funciones | @dataclass / Pydantic; firmas anotadas | mypy, 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 params | ruff 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 restaurante | ruff DTZ, tests |
env() esparcido por el código | os.getenv("X") en cualquier módulo | Solo shared/settings.py; en código settings.X | revisión, grep en CI |
| Controlador de 4.000 líneas | routes.py con la lógica dentro del endpoint | Ruta delgada → queries.py (I/O) → funciones puras (rules.py/utils.py) con test | TESTING.md, cobertura ≥ 90 % en el motor |
| Guzzle sin timeout, un cliente por método | httpx.get(url) suelto, sin timeout, dentro de async def bloqueando | Un httpx.AsyncClient(timeout=…) compartido; await siempre | revisión, tests con mock |
| Copiar el método a cinco controladores | Copiar la función a cinco queries.py | Función en shared/ o helper del módulo; @pytest.mark.parametrize para los casos | revisión |
| IDs de locales quemados en el código | if 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 motivo | guideline 1, revisión |
200 con {"success": false} | return {"error": "…"} con status 200 | JSONResponse(status_code=404, content=error_response(LOCAL_NOT_FOUND, …)); códigos en shared/errors/codes.py | tests de ruta (assert r.status_code == 404) |
| Estado global mutable | Variables de módulo que se modifican, global | Estado 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 cobertura | TESTING.md §0: "¿qué bug de producción atraparía este test?"; si ninguno, no se escribe | gate de cobertura + revisión |
vendor/ commiteado | .venv/ o __pycache__/ commiteado | .gitignore ya los excluye; versiones pineadas en requirements/ | revisión |
| Merge = deploy sin gate | Push a prod sin PR | PR a staging → CI (lint, mypy, tests, cobertura) → merge → PR staging → prod | .github/workflows/ci.yml, ramas protegidas |
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, siempreCLAUDE.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á bloqueadaSin 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.
(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.
"""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")"""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"""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"""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
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) == esperadofrom 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 Nonefrom 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 == 422Fí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 --fillCó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→ mockearlegacy_db_readen el binding local del módulo; orquestador →AsyncMockde 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 losparams; 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 .yruff format --check .limpios;mypydel motor sin errores nuevos.- Todas las firmas nuevas anotadas (
->incluido); sinAnygratuito. - 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 enshared/errors/codes.pyy el status HTTP correcto; ningúnexcept:pelado. - Logs con
extra={}y nivel correcto; ningúnprint/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 astaging; elREADME.mddel módulo actualizado si cambia el contrato. - Puedo explicar cada línea, incluidas las que escribió la IA.
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.
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.
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:
- 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.
- 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". - 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".
Agrega 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.
Crea el móduloModelo a seguir explícito, alcance de un commit, tú controlas el siguiente paso.locales/calcando la estructura decities/(schemas, queries conlegacy_db_ready parámetros nombrados, utils puros, routes conerror_response). Primero soloutils.resolve_tzcon sus tests parametrizados; no toques nada más hasta que yo lo revise.
Recursos, pocos y elegidos
- El libro de PythonEn español, capítulos cortos. La referencia de la semana 1; los enlaces de esta guía apuntan ahí.
- FastAPI · Tutorial (español)Muy bien escrito, del autor. Hasta "Dependencies" es obligatorio; "Concurrency and async / await" también.
- Pydantic v2 · ModelsLo que hay en
schemas.py:Field, validadores, serialización. - Tutorial oficial de Python (español)Para cuando quieras la fuente. Capítulos 4–9.
- pytest · parametrize · unittest.mockLas dos herramientas que TESTING.md usa en todos los ejemplos.
- Ruff · reglasCuando ruff marque algo, busca el código (B006, E722, DTZ005…) aquí: cada regla explica el porqué. Es un curso de buenas prácticas disfrazado.
- SQLAlchemy 2 · Core (text, bindparams)Cómo se parametriza SQL con
:nombre; lo que hacelegacy_db_readpor debajo. - PEP 20 · The Zen of Python · PEP 8 (en El libro)Diecinueve líneas de filosofía y la guía de estilo. Ruff aplica la segunda; la primera se lee.
- En el repo:
CLAUDE.md,services/api/TESTING.md,docs/operations/environments.md,services/api/app/cities/README.mdAntes que cualquier recurso externo. Es la casa.