Python para m-b-core/Prácticas/1 · Entorno, funciones puras y tests
Práctica 1 de 8

Entorno, funciones puras y tests

Creas el mini-repo mesa-core-practicas con la configuración exacta de la casa y escribes el primer motor de disponibilidad en miniatura: una matriz de mesas × slots de 15 minutos y tres reglas de negocio como funciones puras, con tests parametrizados que atrapan un bug antes de que llegue a nadie.

concepto · funciones puras + tests como contrato1 sesión (2–3 h)venv · pip · pytest · ruff · mypy · numpy · dataclasses
ConstruyesRepo con la config de m-b-core + availability_engine/ mini (models, day_matrix, rules) y 23 tests
AprendesMódulos y paquetes, PYTHONPATH, dataclasses, numpy básico, parametrize, ruff/mypy
Calca de m-b-corepyproject.toml, pytest.ini, Makefile, guidelines 1 y 2 de CLAUDE.md, rules.py
Terminas conmake check en verde: lint + tipos + 23 tests

Objetivos

  • Montar el repo como está montado m-b-core: requirements/ pineados, pyproject.toml con la config de ruff y mypy, pytest.ini con asyncio_mode = auto, Makefile que exporta PYTHONPATH.
  • Escribir constantes con nombre y objetos de dominio con @dataclass(frozen=True) (guideline 1).
  • Escribir reglas de negocio como funciones puras apply_*(matrix, …) -> None que mutan una matriz numpy (guideline 2).
  • Escribir tests parametrizados de bordes y ver cómo uno de ellos atrapa un bug real.
  • Dejar ruff check, ruff format --check y mypy en verde, y entender el flag --explicit-package-bases que usa la CI de m-b-core.
Por qué empezar por aquí

El motor de m-b-core es exactamente esto a escala: una DayMatrix de 15 minutos y diez reglas puras que la mutan (services/api/app/availability_engine/rules.py). Si escribes tú una versión de tres reglas con tests, el día que abras el archivo real vas a reconocer la forma en vez de leer 2.600 líneas a ciegas.

Antes de empezar

Necesitas Python 3.12 (la versión de m-b-core; con 3.13/3.14 funciona, pero numpy puede tardar en instalar), git y un editor con la extensión de Python. Comprueba:

python3.12 --version      # o python3 --version si ya es 3.12
git --version
Deberías ver
Python 3.12.13   (cualquier 3.12.x)
git version 2.x

Si tienes uv (del cuaderno de aprendizaje) sirve para instalar Python: uv python install 3.12. En esta ruta usamos venv + pip a propósito, porque es lo que usa m-b-core (requirements/*.txt con versiones exactas y un Makefile).

Construcción paso a paso

Escribe los archivos tú (no pegues bloques enteros: tipear es parte de aprender), corre cada comando y compara con "deberías ver". Las salidas son reales, copiadas de una ejecución con Python 3.12.13.

Paso 1 El repo y su configuración

mkdir mesa-core-practicas && cd mesa-core-practicas
git init -b main
mkdir -p requirements shared/errors services/api/app/availability_engine/tests

La configuración de las herramientas vive en pyproject.toml. Es la misma que m-b-core/pyproject.toml (recortada): ruff con el mismo conjunto de reglas y mypy con las mismas opciones.

pyproject.toml
[tool.ruff]
target-version = "py312"
line-length = 100
extend-exclude = [".venv", "alembic/versions", "__pycache__"]

[tool.ruff.lint]
select = [
    "E",  # pycodestyle errors
    "W",  # pycodestyle warnings
    "F",  # pyflakes
    "I",  # isort
    "B",  # flake8-bugbear
    "C4", # flake8-comprehensions
    "UP", # pyupgrade
]
ignore = [
    "E501",  # line too long — lo maneja el formatter
    "B008",  # function calls in argument defaults — Depends() de FastAPI
]

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
check_untyped_defs = true
warn_unused_ignores = true
warn_return_any = true

pytest se configura aparte, en pytest.ini (m-b-core lo mantiene ahí "para que pytest desde cualquier directorio lo encuentre sin ambigüedad"):

pytest.ini
[pytest]
testpaths = services/api/app shared
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -q --tb=short -m "not e2e"
asyncio_mode = auto
markers =
    unit: funciones puras sin I/O (rápidas, sin fixtures)
    integration: HTTP / motor con dependencias externas simuladas
    e2e: pega a servicios reales; excluido por defecto (pytest -m e2e)

Dependencias con versión exacta, en dos archivos: lo que corre en producción y lo que solo hace falta para desarrollar. Fíjate que local.txt incluye a base.txt.

requirements/base.txt
# Runtime mínimo (versiones exactas, como en m-b-core)
numpy==2.2.3
requirements/local.txt
-r base.txt
# Tests
pytest==8.2.2
pytest-asyncio==0.23.7
pytest-cov==6.1.1
# Lint / format / tipos
ruff==0.6.9
mypy==1.14.1
.gitignore
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.pyc
.env
env/*.env

Los paquetes se marcan con un __init__.py vacío. Fíjate en cuáles: services/ y services/api/ no lo llevan (igual que en m-b-core: el paquete raíz es app/), y eso tendrá una consecuencia con mypy en el paso 7.

touch shared/__init__.py shared/errors/__init__.py \
      services/api/app/__init__.py \
      services/api/app/availability_engine/__init__.py \
      services/api/app/availability_engine/tests/__init__.py
Por qué versiones exactas y sin lock. m-b-core pinea a mano en requirements/ (no hay uv.lock ni pip-compile): es una decisión del repo con un costo conocido (las transitivas no están fijadas). En esta ruta seguimos la convención de la casa; la guía de criterio ya propone añadir un lock. Lo importante hoy: == siempre, nunca >=.

Paso 2 venv, pip y el Makefile

Cada proyecto tiene su propio Python en .venv/. El Makefile lo crea, instala y, sobre todo, exporta PYTHONPATH := .: así from services.api.app… import se resuelve desde la raíz del repo, igual que en m-b-core.

Makefile
PY ?= python3
VENV := .venv
PIP := $(VENV)/bin/pip
PYTEST := $(VENV)/bin/pytest
RUFF := $(VENV)/bin/ruff
MYPY := $(VENV)/bin/mypy

# Todo import es absoluto desde la raíz del repo (services.api.app…, shared…)
export PYTHONPATH := .

.PHONY: venv install tests lint typecheck check

venv:
	$(PY) -m venv $(VENV)
	$(PIP) install --upgrade pip

install: venv
	$(PIP) install -r requirements/local.txt

tests:
	$(PYTEST)

lint:
	$(RUFF) check .
	$(RUFF) format --check .

typecheck:
	$(MYPY) services/api/app/availability_engine/

check: lint typecheck tests

Ojo: las líneas de receta del Makefile van con TAB, no con espacios. Si tu editor convierte tabs a espacios, verás *** missing separator.

make install PY=python3.12        # o simplemente `make install` si python3 ya es 3.12
.venv/bin/python --version
Deberías ver
Successfully installed coverage-7.15.4 iniconfig-2.3.0 mypy-1.14.1 mypy_extensions-1.1.0 numpy-2.2.3
packaging-26.3 pluggy-1.6.0 pytest-8.2.2 pytest-asyncio-0.23.7 pytest-cov-6.1.1 ruff-0.6.9 typing_extensions-4.16.0
Python 3.12.13
git add -A
git commit -m "Estructura inicial: config de ruff/mypy/pytest, requirements y Makefile"
Sobre activar el venv. Puedes hacer source .venv/bin/activate y luego escribir pytest a secas; en esta guía llamamos siempre .venv/bin/… o make … para que quede claro qué Python corre. Es la forma que no falla cuando tienes tres proyectos abiertos.

Paso 3 Constantes y objetos de dominio

Guideline 1 de m-b-core: "No magic strings/numbers. Use the constants in models.py". Antes de escribir una sola regla, definimos los valores con nombre y la forma de los datos.

services/api/app/availability_engine/models.py
"""Objetos de dominio y constantes del motor de disponibilidad (mini).

Todo valor que se repite vive aquí con nombre (guideline 1 de m-b-core:
"No magic strings/numbers"). El motor real está en
services/api/app/availability_engine/models.py de m-b-core.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import IntEnum, unique

# ---------------------------------------------------------------------------
# Constantes
# ---------------------------------------------------------------------------

ESTADO_ACTIVO = 1
MINUTES_PER_SLOT = 15
MINUTES_PER_HOUR = 60
MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR  # 1440
SLOTS_PER_DAY = MINUTES_PER_DAY // MINUTES_PER_SLOT  # 96
DEFAULT_TIMEZONE = "America/Lima"


# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


@unique
class SlotStatus(IntEnum):
    """Estado de una celda de la matriz. Negativo = bloqueado, 0 = libre, positivo = ocupado."""

    BLOCKED = -2  # bloqueada por una regla (cierre, capacidad)
    OUTSIDE = -1  # fuera del horario de atención
    AVAILABLE = 0  # libre para reservar
    OCCUPIED = 1  # ya reservada


# ---------------------------------------------------------------------------
# Objetos de dominio
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Table:
    """Mesa física con sus límites de capacidad."""

    id: int
    min_pax: int
    max_pax: int
    disponible_reserva: bool = True


@dataclass(frozen=True)
class Slot:
    """Un intervalo de MINUTES_PER_SLOT minutos, identificado por su índice en el día."""

    index: int

    @property
    def minute_of_day(self) -> int:
        return self.index * MINUTES_PER_SLOT

    @property
    def label(self) -> str:
        """'HH:MM' del inicio del slot."""
        hours, minutes = divmod(self.minute_of_day, MINUTES_PER_HOUR)
        return f"{hours:02d}:{minutes:02d}"
Lo que hay aquí que no había en PHP. @dataclass(frozen=True) genera __init__, __repr__ y __eq__ por ti y hace la instancia inmutable: Table(1, 1, 2) es un valor, no una bolsa $data[]. IntEnum con @unique es un conjunto cerrado de estados que además se comporta como entero (por eso cabe en una matriz numpy). @property es el getter sin paréntesis. divmod devuelve cociente y resto de una vez. Y el from __future__ import annotations de arriba permite escribir tipos como int | None sin importar nada: m-b-core lo pone en todos los módulos.

Paso 4 La matriz del día

El motor real construye una grilla por día: una fila por mesa, una columna por cada 15 minutos (96 columnas), y todo lo que pasa (reservas, cierres, bloqueos) se proyecta ahí. Nuestra versión mínima:

services/api/app/availability_engine/day_matrix.py
"""Matriz del día: una fila por mesa, una columna por slot de 15 minutos.

Es la versión mínima de services/api/app/availability_engine/day_matrix.py
de m-b-core: el motor real construye una grilla por día donde TODAS las
reservas y cierres se proyectan, y las reglas la mutan en sitio.
"""

from __future__ import annotations

import numpy as np

from .models import MINUTES_PER_DAY, MINUTES_PER_SLOT, SLOTS_PER_DAY, SlotStatus, Table


def slot_index(minute_of_day: int) -> int:
    """Índice de columna para un minuto del día (0..1439)."""
    if not 0 <= minute_of_day < MINUTES_PER_DAY:
        raise ValueError(f"minute_of_day fuera de rango: {minute_of_day}")
    return minute_of_day // MINUTES_PER_SLOT


def build_day_matrix(tables: list[Table]) -> np.ndarray:
    """Devuelve una matriz (len(tables), 96) con todo AVAILABLE.

    Las mesas conservan el orden de la lista: la fila i es tables[i].
    """
    if not tables:
        raise ValueError("no hay mesas: la matriz sería vacía")
    return np.full((len(tables), SLOTS_PER_DAY), SlotStatus.AVAILABLE, dtype=np.int8)


def available_slots(matrix: np.ndarray, row: int) -> list[int]:
    """Índices de slot libres para la fila (mesa) indicada."""
    return [int(i) for i in np.flatnonzero(matrix[row] == SlotStatus.AVAILABLE)]

Pruébalo en el intérprete antes de seguir. Es la forma más rápida de entender numpy: una operación se aplica a toda la matriz de una vez, sin bucles.

PYTHONPATH=. .venv/bin/python -c "
from services.api.app.availability_engine.day_matrix import build_day_matrix, slot_index
from services.api.app.availability_engine.models import Table
m = build_day_matrix([Table(1, 1, 2), Table(2, 2, 4)])
print(m.shape, m.dtype, slot_index(20*60))
print((m == 0).sum(), 'celdas libres')"
Deberías ver
(2, 96) int8 80
192 celdas libres
Cuatro cosas de Python en 20 líneas. (1) 0 <= x < 1440 encadena comparaciones: es una sola expresión, no dos. (2) // es división entera (en PHP sería intdiv). (3) [int(i) for i in …] es una comprensión de lista: el array_map de aquí; el int() convierte el numpy.int64 a int de Python para que el resultado sea serializable. (4) if not tables: — una lista vacía es falsa; una lista con elementos, verdadera. Es la validación al borde con la que empieza toda función de la casa: fallar temprano y claro.

Paso 5 Reglas de negocio como funciones puras

Guideline 2 de m-b-core: "Every rule in rules.py is a pure function: apply_*(engine, sa, target_date) → None. It takes explicit inputs and never touches global state." Nuestra firma es más simple (apply_*(matrix, …)) pero es la misma idea: entradas explícitas, la única salida es la matriz que reciben, nada de estado global.

services/api/app/availability_engine/rules.py
"""Reglas de negocio como funciones puras que mutan la matriz en sitio.

Misma firma en todas (guideline 2 de m-b-core, ver
services/api/app/availability_engine/rules.py):
    apply_*(matrix, tables, ...) -> None
Reciben entradas explícitas, no tocan estado global, y son fáciles de testear
con parametrize porque su única salida es la matriz que reciben.
"""

from __future__ import annotations

import numpy as np

from .day_matrix import slot_index
from .models import MINUTES_PER_DAY, SLOTS_PER_DAY, SlotStatus, Table


def _mark(matrix: np.ndarray, rows: np.ndarray, cols: slice, status: SlotStatus) -> None:
    """Pone `status` en las celdas indicadas que aún estén AVAILABLE."""
    sub = matrix[rows, cols]
    matrix[rows, cols] = np.where(sub == SlotStatus.AVAILABLE, status, sub)


def apply_opening_hours(matrix: np.ndarray, open_minute: int, close_minute: int) -> None:
    """Marca OUTSIDE todo lo que cae antes de la apertura o desde el cierre.

    Cierre exclusivo: si cierra a las 23:00, el slot 23:00 ya está fuera.
    """
    if not open_minute < close_minute:
        raise ValueError("open_minute debe ser menor que close_minute")
    all_rows = np.arange(matrix.shape[0])
    _mark(matrix, all_rows, slice(0, slot_index(open_minute)), SlotStatus.OUTSIDE)
    _mark(matrix, all_rows, slice(slot_index(close_minute), None), SlotStatus.OUTSIDE)


def apply_min_capacity(matrix: np.ndarray, tables: list[Table], pax: int) -> None:
    """Bloquea las mesas que no admiten `pax` personas (fuera de [min_pax, max_pax])."""
    if pax < 1:
        raise ValueError("pax debe ser >= 1")
    rows = np.array(
        [i for i, t in enumerate(tables) if not (t.min_pax <= pax <= t.max_pax)],
        dtype=int,
    )
    if rows.size:
        _mark(matrix, rows, slice(None), SlotStatus.BLOCKED)


def apply_reservation(matrix: np.ndarray, row: int, start_minute: int, duration_minutes: int) -> None:
    """Ocupa los slots de una reserva existente en la mesa `row`."""
    if duration_minutes <= 0:
        raise ValueError("duration_minutes debe ser > 0")
    first = slot_index(start_minute)
    last = slot_index(min(start_minute + duration_minutes - 1, MINUTES_PER_DAY - 1)) + 1
    matrix[row, first:last] = SlotStatus.OCCUPIED

Esta versión de apply_opening_hours tiene un bug. No lo corrijas todavía: lo va a encontrar un test en el paso siguiente, que es exactamente para lo que sirven.

Detalles que importan. El guion bajo de _mark significa "privado del módulo" (convención, no llave: Python no tiene private). _mark solo pisa celdas AVAILABLE: una regla nunca borra una reserva ya puesta, y eso también lo vamos a testear. enumerate(tables) te da índice y elemento a la vez (el foreach ($xs as $i => $x)). Y mira if rows.size: en vez de if rows:: un array numpy no tiene valor de verdad; si escribes if rows: obtienes ValueError: The truth value of an array with more than one element is ambiguous. Es la trampa número uno de numpy para quien viene de listas.

Paso 6 Tests parametrizados que atrapan un bug

Un test por caso de borde, sin mocks (es una función pura: entrada conocida, salida conocida). @pytest.mark.parametrize convierte una tabla de casos en N tests con nombre. Es la forma que TESTING.md de m-b-core pide para funciones puras.

services/api/app/availability_engine/tests/test_day_matrix.py
import numpy as np
import pytest

from services.api.app.availability_engine.day_matrix import (
    available_slots,
    build_day_matrix,
    slot_index,
)
from services.api.app.availability_engine.models import SLOTS_PER_DAY, SlotStatus, Table

MESA_2 = Table(id=1, min_pax=1, max_pax=2)
MESA_4 = Table(id=2, min_pax=2, max_pax=4)


@pytest.mark.parametrize(
    "minute, esperado",
    [(0, 0), (14, 0), (15, 1), (12 * 60, 48), (23 * 60 + 59, 95)],
)
def test_slot_index(minute, esperado):
    assert slot_index(minute) == esperado


@pytest.mark.parametrize("minute", [-1, 24 * 60])
def test_slot_index_fuera_de_rango(minute):
    with pytest.raises(ValueError):
        slot_index(minute)


def test_build_day_matrix_forma_y_estado():
    m = build_day_matrix([MESA_2, MESA_4])
    assert m.shape == (2, SLOTS_PER_DAY)
    assert m.dtype == np.int8
    assert (m == SlotStatus.AVAILABLE).all()


def test_build_day_matrix_sin_mesas_falla():
    with pytest.raises(ValueError):
        build_day_matrix([])


def test_available_slots_devuelve_todos_al_inicio():
    m = build_day_matrix([MESA_2])
    assert available_slots(m, 0) == list(range(SLOTS_PER_DAY))
services/api/app/availability_engine/tests/test_rules.py
import pytest

from services.api.app.availability_engine.day_matrix import available_slots, build_day_matrix
from services.api.app.availability_engine.models import SlotStatus, Table
from services.api.app.availability_engine.rules import (
    apply_min_capacity,
    apply_opening_hours,
    apply_reservation,
)

MESA_2 = Table(id=1, min_pax=1, max_pax=2)
MESA_4 = Table(id=2, min_pax=2, max_pax=4)
MESA_8 = Table(id=3, min_pax=5, max_pax=8)


@pytest.mark.parametrize(
    "open_h, close_h, libres_esperados",
    [
        (12, 23, 44),  # 11 horas x 4 slots
        (0, 24, 96),  # abierto todo el día
        (19, 20, 4),  # una hora
    ],
)
def test_apply_opening_hours_deja_solo_el_horario(open_h, close_h, libres_esperados):
    m = build_day_matrix([MESA_2])
    apply_opening_hours(m, open_h * 60, close_h * 60)
    assert len(available_slots(m, 0)) == libres_esperados


def test_apply_opening_hours_marca_outside_no_blocked():
    m = build_day_matrix([MESA_2])
    apply_opening_hours(m, 12 * 60, 23 * 60)
    assert m[0, 0] == SlotStatus.OUTSIDE
    assert m[0, 95] == SlotStatus.OUTSIDE
    assert m[0, 48] == SlotStatus.AVAILABLE  # 12:00


def test_apply_opening_hours_invalido():
    m = build_day_matrix([MESA_2])
    with pytest.raises(ValueError):
        apply_opening_hours(m, 20 * 60, 12 * 60)


@pytest.mark.parametrize(
    "pax, filas_bloqueadas",
    [
        (1, {1, 2}),  # solo MESA_2 admite 1
        (2, {2}),  # MESA_2 y MESA_4 admiten 2
        (4, {0, 2}),  # solo MESA_4
        (6, {0, 1}),  # solo MESA_8
        (9, {0, 1, 2}),  # nadie
    ],
)
def test_apply_min_capacity(pax, filas_bloqueadas):
    tables = [MESA_2, MESA_4, MESA_8]
    m = build_day_matrix(tables)
    apply_min_capacity(m, tables, pax)
    bloqueadas = {i for i in range(len(tables)) if (m[i] == SlotStatus.BLOCKED).all()}
    assert bloqueadas == filas_bloqueadas


def test_apply_min_capacity_pax_invalido():
    m = build_day_matrix([MESA_2])
    with pytest.raises(ValueError):
        apply_min_capacity(m, [MESA_2], 0)


def test_apply_reservation_ocupa_los_slots_de_la_duracion():
    m = build_day_matrix([MESA_2])
    apply_reservation(m, row=0, start_minute=20 * 60, duration_minutes=90)  # 20:00–21:30
    ocupados = [i for i in range(96) if m[0, i] == SlotStatus.OCCUPIED]
    assert ocupados == list(range(80, 86))  # 20:00,20:15,…,21:15


def test_las_reglas_no_pisan_lo_que_ya_estaba_ocupado():
    m = build_day_matrix([MESA_2])
    apply_reservation(m, 0, 20 * 60, 60)
    apply_opening_hours(m, 12 * 60, 21 * 60)  # el cierre 21:00 cae encima de la reserva
    assert m[0, 80] == SlotStatus.OCCUPIED  # 20:00 sigue ocupado, no OUTSIDE
make tests
Deberías ver (rojo, y es lo que queremos)
    raise ValueError(f"minute_of_day fuera de rango: {minute_of_day}")
E   ValueError: minute_of_day fuera de rango: 1440
=========================== short test summary info ============================
FAILED services/api/app/availability_engine/tests/test_rules.py::test_apply_opening_hours_deja_solo_el_horario[0-24-96]
1 failed, 22 passed in 0.43s
make: *** [tests] Error 1

El caso [0-24-96] ("abierto todo el día") pide slot_index(1440), y 1440 no es un minuto del día: es "el final". Es un borde que se ve en el test y no se veía en el código. Corrige la regla para tratar el cierre a medianoche como "hasta el final":

services/api/app/availability_engine/rules.py — apply_opening_hours corregida
def apply_opening_hours(matrix: np.ndarray, open_minute: int, close_minute: int) -> None:
    """Marca OUTSIDE todo lo que cae antes de la apertura o desde el cierre.

    Cierre exclusivo: si cierra a las 23:00, el slot 23:00 ya está fuera.
    """
    if not 0 <= open_minute < close_minute <= MINUTES_PER_DAY:
        raise ValueError("se requiere 0 <= open_minute < close_minute <= 1440")
    all_rows = np.arange(matrix.shape[0])
    # El cierre a medianoche (1440) no es un slot válido: es "hasta el final".
    close_idx = SLOTS_PER_DAY if close_minute == MINUTES_PER_DAY else slot_index(close_minute)
    _mark(matrix, all_rows, slice(0, slot_index(open_minute)), SlotStatus.OUTSIDE)
    _mark(matrix, all_rows, slice(close_idx, None), SlotStatus.OUTSIDE)
make tests
Deberías ver
.venv/bin/pytest
.......................                                                  [100%]
23 passed in 0.03s

Corre también con -v para ver cómo parametrize nombra cada caso ([0-24-96], [4-{0, 2}]…): cuando uno falle en el futuro, el nombre te dice qué borde se rompió.

PYTHONPATH=. .venv/bin/pytest -v
Deberías ver (recortado)
collected 23 items
services/api/app/availability_engine/tests/test_day_matrix.py ..........  [ 43%]
services/api/app/availability_engine/tests/test_rules.py .............    [100%]
============================== 23 passed in 0.03s ==============================
Qué acaba de pasar. Escribiste tres casos que "obviamente" funcionaban y uno no. TESTING.md §0 pregunta antes de escribir un test: "¿qué bug de producción atraparía?". Este atrapó "un restaurante abierto 24 h no tiene disponibilidad". Y fíjate en test_las_reglas_no_pisan_lo_que_ya_estaba_ocupado: no prueba una función, prueba un invariante del sistema (las reglas solo tocan celdas libres). Esos son los tests que valen.

Paso 7 ruff, mypy y el primer commit del motor

make lint
Deberías ver (probablemente)
services/api/app/availability_engine/rules.py:10:1: I001 [*] Import block is un-sorted or un-formatted
Found 1 error.
[*] 1 fixable with the `--fix` option.

Ruff ordena imports (regla I): SlotStatus va antes que Table. No discutas con él: es el punto de tener una herramienta. Arréglalo y formatea:

.venv/bin/ruff check --fix .
.venv/bin/ruff format .
make lint
Deberías ver
Found 1 error (1 fixed, 0 remaining).
1 file reformatted, 9 files left unchanged
All checks passed!
10 files already formatted

Ahora los tipos. Corre make typecheck tal como está el Makefile del paso 2:

Deberías ver
services/api/app/availability_engine/day_matrix.py: error: Source file found twice under different
module names: "app.availability_engine.day_matrix" and "services.api.app.availability_engine.day_matrix"
Found 1 error in 1 file (errors prevented further checking)

Es el mismo problema que resolvió la CI de m-b-core (.github/workflows/ci.yml): como services/ y services/api/ no tienen __init__.py, mypy resuelve cada módulo bajo dos nombres. La solución es el flag --explicit-package-bases. Actualiza el Makefile:

Makefile — target typecheck
typecheck:
	# --explicit-package-bases: services/ y services/api/ no tienen __init__.py a propósito
	# (el paquete raíz es app/); sin el flag mypy ve cada módulo bajo dos nombres.
	$(MYPY) services/api/app/availability_engine/ --explicit-package-bases
make typecheck
make check
Deberías ver
Success: no issues found in 7 source files
…
23 passed in 0.03s
git add -A
git commit -m "Motor mini: modelos, matriz del día y 3 reglas puras con tests"
git log --oneline
Deberías ver
1105c79 Motor mini: modelos, matriz del día y 3 reglas puras con tests
b9379a7 Estructura inicial: config de ruff/mypy/pytest, requirements y Makefile

Entiende el código (y dónde está lo mismo en m-b-core)

Lo que hicisteEn m-b-coreQué mirar allí
pyproject.toml con ruff select = E,W,F,I,B,C4,UP y mypypyproject.tomlLos mismos bloques, más excepciones por archivo (main.py con E402 porque carga el env antes de importar). La guía de criterio propone añadir DTZ, BLE, G, S608; en las prácticas 4, 6 y 7 los vas a activar tú.
pytest.ini con asyncio_mode = auto y marcaspytest.iniIdéntico en espíritu; añade filterwarnings (la guía discute por qué silenciar warnings en bloque es un vicio).
Makefile con export PYTHONPATH := .Makefile:9Y ci.yml pone PYTHONPATH: ${{ github.workspace }} por la misma razón.
models.py: constantes + SlotStatus(IntEnum) + @dataclass(frozen=True) class Tableavailability_engine/models.py:29-75ACTIVE_RESERVATION_STATES, MINUTES_PER_HOUR, DEFAULT_TIMEZONE; el mismo SlotStatus con los mismos cuatro valores; Table con min_pax/max_pax/disponible_reserva.
day_matrix.py: np.full((mesas, 96), AVAILABLE)availability_engine/day_matrix.pyDAY_SLOT_SIZE = 15, generate_slot_times; la matriz real guarda además el id de reserva por celda.
rules.py: apply_*(matrix, …) -> None, _mark que solo toca AVAILABLEavailability_engine/rules.pyblock_all_slots(mat) hace exactamente el np.where(mask, BLOCKED, mat) de tu _mark; cada apply_* lleva la referencia a la línea de SearchV2.php del legacy que reproduce.
Tests parametrizados de bordes, sin mocksservices/api/TESTING.md §1 fila "Pure function""Parametrize edges directly. Mock target: None". test_processing.py, test_builder_edge.py.
mypy … --explicit-package-bases.github/workflows/ci.yml job typecheckEl comentario explica lo mismo que viste: paquete raíz app/, sin __init__.py en services/.

Python que aprendiste de paso

  • Módulos y paquetes: archivo = módulo, carpeta con __init__.py = paquete, imports absolutos desde la raíz gracias a PYTHONPATH; from .models import … es import relativo dentro del mismo paquete (módulos, paquetes).
  • dataclasses e IntEnum: datos con forma en vez de dicts (clases, property).
  • Comprensiones y enumerate (list comprehension, enumerate); // y comparaciones encadenadas (operadores).
  • Excepciones para fallar temprano y pytest.raises para exigirlas (excepciones, testing).
  • numpy lo justo: np.full, indexación m[filas, cols], np.where, .all(), .size; y que un array no tiene valor de verdad.

Con Claude

Esta práctica se escribe a mano (regla 1: la primera vez, tú). Claude Code sirve para entender, no para escribir. Crea el CLAUDE.md del mini-repo y úsalo así:

CLAUDE.md
# mesa-core-practicas

Repo de aprendizaje: calca la estructura y convenciones de m-b-core para que
yo aprenda a hacer PRs allí. Estoy aprendiendo Python viniendo de PHP.

## Reglas para ti
- No escribas código a menos que te lo pida explícitamente. Explica y pregunta.
- Si te pido código, el cambio más pequeño posible, siguiendo CLAUDE.md y
  TESTING.md de m-b-core: constantes en models.py, reglas como funciones puras
  apply_*() -> None, tests parametrizados sin mocks para funciones puras.
- Nunca modifiques los tests para que pasen. Si un test está mal, dilo.
- Antes de decir "listo": make check (ruff, mypy, pytest) tiene que pasar.
- Responde en español.

Pedidos que valen la pena aquí

Explícame línea por línea _mark(): qué hace np.where, por qué matrix[rows, cols] con un array y un slice, y qué pasaría si rows estuviera vacío. Compara mi apply_opening_hours con block_all_slots y apply_closed_slots_turno de m-b-core (availability_engine/rules.py). ¿Qué convención de la casa no estoy siguiendo? Este test falla: [pegar salida]. No me des la solución: dime qué borde estoy pisando y por qué el diseño de "cierre exclusivo" lo produce. Hazme cinco preguntas de criterio sobre dataclasses frozen, IntEnum y funciones puras, y corrige mis respuestas con dureza.

Si algo falla

ModuleNotFoundError: No module named 'services' al correr pytest

Corriste .venv/bin/pytest a secas, sin PYTHONPATH=.. Los imports son absolutos desde la raíz del repo y Python no la conoce si no se lo dices. Usa make tests (el Makefile lo exporta) o PYTHONPATH=. .venv/bin/pytest. En m-b-core es idéntico: es la primera cosa que rompe la primera vez.

mypy: Source file found twice under different module names

Falta --explicit-package-bases (paso 7). Ocurre porque services/ y services/api/ no son paquetes a propósito. No "arregles" añadiendo __init__.py ahí: rompes la convención de la casa. Añade el flag.

ValueError: The truth value of an array with more than one element is ambiguous

Escribiste if rows: (o if matrix == 0:) sobre un array numpy. Un array con más de un elemento no es ni verdadero ni falso. Usa rows.size, (m == 0).any() o .all() según lo que quieras preguntar.

Makefile:12: *** missing separator. Stop.

Las líneas de receta (las de debajo de tests:, lint:…) tienen que empezar con un TAB real. Tu editor probablemente lo convirtió a espacios. En VS Code: abre el Makefile, abajo a la derecha "Spaces: 4" → cambia a "Indent Using Tabs".

make install tarda minutos compilando numpy, o falla

Estás con un Python muy nuevo (3.13/3.14) para el que no hay rueda precompilada de numpy==2.2.3. Usa 3.12, la versión de m-b-core: make install PY=python3.12 (o instálalo con uv python install 3.12 / brew install python@3.12).

ruff I001 Import block is un-sorted aunque "se ve bien"

El orden es alfabético dentro de cada grupo (stdlib, terceros, propio) y ruff es estricto: SlotStatus va antes que Table. No lo ordenes a mano: ruff check --fix . y ruff format .. Nadie revisa estilo en un PR de m-b-core; lo hace la herramienta.

Listo cuando

  • make check pasa entero: ruff sin errores, formato correcto, mypy sin errores, 23 tests en verde.
  • Rompiste una regla a propósito y viste qué test la atrapó y qué decía el nombre del caso.
  • Puedes explicar por qué _mark solo toca celdas AVAILABLE y qué invariante protege eso.
  • Sabes por qué el Makefile exporta PYTHONPATH y por qué mypy necesita --explicit-package-bases.
  • Abriste services/api/app/availability_engine/rules.py de m-b-core y reconociste la forma: docstring del módulo, imports, block_all_slots, apply_*.
  • Dos commits con mensaje en imperativo que dicen el qué; el repo se puede clonar y make install && make check pasa en otra máquina.

Siguiente

En la Práctica 2 · async/await de verdad el mini-repo llama a servicios externos con httpx.AsyncClient, timeouts, asyncio.gather con Semaphore y to_thread para el trabajo pesado: el cambio de modelo mental más grande respecto a PHP-FPM, y la razón por la que en m-b-core todo lo que toca I/O es async def.