Python para m-b-core/Prácticas/6 · Fechas por restaurante
Práctica 6 de 8

Fechas por restaurante

El servidor está en UTC y los restaurantes en Lima, Santiago, Quito y Cancún. Aquí escribes las funciones de zona horaria de m-b-core tal como existen, entiendes por qué now_local() devuelve un datetime naive a propósito, reproduces en un test la trampa de las 19:00, y dejas al linter vigilando para que no vuelva.

concepto · la zona es un dato del restaurante, no del servidor2 sesioneszoneinfo · freezegun · ruff DTZ · guideline 6
Construyesshared/timezone.py, availability_engine/timezones.py, disponibilidad/rules.py + 23 tests
Aprendesnaive vs aware, ZoneInfo, inyectar "ahora", DST, ruff DTZ
Igual que en m-b-coretz_for, now_local, today_for_venue: mismas firmas y prioridad
Terminas conUn test que falla si alguien vuelve a poner datetime.now() a secas

Objetivos

  • Escribir tz_for(zona_horaria, pais) con la prioridad de la casa: Ubigeo.ZonaHoraria primero, país como fallback, America/Lima por defecto.
  • Distinguir un datetime naive de uno aware, saber cuál devuelve now_local() en m-b-core y por qué, y cuándo usar el otro.
  • Reproducir la trampa de las 19:00 (a las 19:30 de Lima, UTC ya está en mañana) como test, y ver la respuesta correcta y la equivocada.
  • Escribir reglas puras que reciben ahora por parámetro, nunca lo calculan.
  • Ver con tus ojos que Santiago cambia de offset en verano y que México tiene varias zonas: por eso la zona viene de Ubigeo, no del país.
  • Activar ruff DTZ y entender qué marcaría hoy en m-b-core.
Por qué esta práctica existe

La guía de criterio tiene el vicio 4 ("la zona horaria como constante del código y restar cinco horas") con 570 Carbon::now() sin zona en el legacy. Y la segunda ronda encontró que el core nuevo lo repite en tres sitios: engine.compute() con now = datetime.now() naive por defecto, date.today() a 170 líneas del comentario que explica por qué no usarlo, y datetime.utcnow() + timedelta(hours=-5) en libro/reservation_detail.py:891. Esta práctica es para que tú no seas el cuarto.

Antes de empezar

Necesitas el mini-repo mesa-core-practicas con lo que dejaron las prácticas 1, 3 y 4: pyproject.toml con la config de ruff/mypy, pytest.ini con asyncio_mode = auto, requirements/, Makefile con PYTHONPATH := ., el módulo availability_engine/ con models.py (constantes y dataclasses), y services/api/app/locales/. Verifica que todo sigue en verde:

cd mesa-core-practicas
source .venv/bin/activate
make tests && make lint

Agrega dos dependencias de desarrollo y reinstala. freezegun congela el reloj en los tests; tzdata trae la base de zonas horarias para que ZoneInfo funcione igual en tu laptop y en la imagen python:3.12-slim (que no la incluye).

requirements/base.txt (agregar)
tzdata==2026.1
requirements/local.txt (agregar)
freezegun==1.5.1
pip install -r requirements/local.txt
python -c "from zoneinfo import ZoneInfo; print(ZoneInfo('America/Santiago'))"
Deberías ver
America/Santiago

En models.py ya tienes DEFAULT_TIMEZONE = "America/Lima" (guideline 1). Si no, agrégalo junto a ESTADO_ACTIVO y MINUTES_PER_SLOT. Y asegúrate de que Local tiene el campo zona_horaria: str | None: es la columna Ubigeo.ZonaHoraria, que en el legacy a veces es NULL.

Construcción paso a paso

Paso 1 La zona por país: solo un fallback

Empieza por lo compartido. shared/timezone.py guarda el mapa país → zona IANA y dos funciones que m-b-core usa desde varios módulos. Fíjate en el orden de today_for_venue: primero la zona del local, luego el país, luego el default. Y en que "" cuenta como "falta" (en Python la cadena vacía es falsy: la trampa 2 de la guía jugando a favor).

shared/timezone.py
"""Zona horaria por país y por local — la copia didáctica de shared/timezone.py de m-b-core.

Prioridad (guideline 6 de CLAUDE.md): Ubigeo.ZonaHoraria primero; el país solo
como fallback; nunca asumir por país cuando hay dato (México y Brasil tienen
varias zonas).
"""

from __future__ import annotations

from datetime import date, datetime
from zoneinfo import ZoneInfo

COUNTRY_TIMEZONES: dict[str, str] = {
    "AR": "America/Argentina/Buenos_Aires",
    "BO": "America/La_Paz",
    "BR": "America/Sao_Paulo",
    "CL": "America/Santiago",
    "CO": "America/Bogota",
    "EC": "America/Guayaquil",
    "MX": "America/Mexico_City",
    "PE": "America/Lima",
}

# Se usa cuando falta el país o no lo conocemos. Lima es el mercado más grande.
DEFAULT_TIMEZONE = "America/Lima"


def today_in_country(country_code: str) -> date:
    """Fecha de hoy en la zona del país (fallback por país)."""
    tz_name = COUNTRY_TIMEZONES.get(country_code.upper(), DEFAULT_TIMEZONE)
    return datetime.now(ZoneInfo(tz_name)).date()


def today_for_venue(zona_horaria: str | None, pais: str | None) -> date:
    """Fecha de hoy en la zona del local: ZonaHoraria → país → default."""
    if zona_horaria:
        tz_name = zona_horaria
    elif pais:
        tz_name = COUNTRY_TIMEZONES.get(pais.upper(), DEFAULT_TIMEZONE)
    else:
        tz_name = DEFAULT_TIMEZONE
    return datetime.now(ZoneInfo(tz_name)).date()
¿Por qué "México → Mexico_City" si México tiene varias zonas? Porque es solo el fallback cuando el legacy no tiene Ubigeo.ZonaHoraria. Cancún (Quintana Roo, UTC−5 fijo) y Ciudad de México (UTC−6) están a una hora; si asumieras por país, un restaurante de Cancún abriría una hora tarde. Lo verás en el paso 5. Es exactamente lo que dice CLAUDE.md guideline 6: "Use Ubigeo.ZonaHoraria, never assume by country. Mexico and Brazil span multiple timezones."

Paso 2 tz_for y now_local: el "ahora" del restaurante

Ahora el módulo del motor. Son las funciones reales de services/api/app/availability_engine/timezones.py de m-b-core (líneas 38–50), más una que agregamos para enseñar la diferencia: now_local_aware.

services/api/app/availability_engine/timezones.py
"""Resolución de zona horaria y "ahora" del restaurante.

Prioridad:
    1. Ubigeo.ZonaHoraria (string IANA de la BD, por restaurante)
    2. País (solo para datos legacy sin Ubigeo)

Los horarios (Reserva.Horario, ProgramacionReserva.HoraInicio) están guardados
en hora local del restaurante, SIN zona. Por eso `now_local()` devuelve un
datetime naive: para comparar naive contra naive en el mismo marco. Es una
decisión de m-b-core (availability_engine/timezones.py:47-50); tiene un
coste, ver `now_local_aware` y la práctica.
"""

from __future__ import annotations

from datetime import datetime
from zoneinfo import ZoneInfo

from shared.timezone import COUNTRY_TIMEZONES

from .models import DEFAULT_TIMEZONE


def tz_for(zona_horaria: str | None = None, pais: str | None = None) -> str:
    """IANA tz del restaurante: primero Ubigeo.ZonaHoraria, luego país, luego default."""
    if zona_horaria:
        return zona_horaria
    if pais:
        return COUNTRY_TIMEZONES.get(pais.upper(), DEFAULT_TIMEZONE)
    return DEFAULT_TIMEZONE


def now_local(zona_horaria: str | None = None, pais: str | None = None) -> datetime:
    """'Ahora' en la pared del restaurante, NAIVE (como m-b-core).

    Úsalo solo para comparar con horarios naive del legacy (Reserva.Horario).
    """
    tz = ZoneInfo(tz_for(zona_horaria, pais))
    return datetime.now(tz).replace(tzinfo=None)


def now_local_aware(zona_horaria: str | None = None, pais: str | None = None) -> datetime:
    """'Ahora' en la zona del restaurante, AWARE (lleva tzinfo).

    Úsalo para todo lo demás: guardar, comparar entre locales, calcular
    diferencias, serializar. Un aware nunca se confunde con un UTC naive.
    """
    return datetime.now(ZoneInfo(tz_for(zona_horaria, pais)))

Los tests. Uno parametrizado para la prioridad de tz_for (incluye la cadena vacía, México con y sin Ubigeo, y país desconocido) y dos con freezegun que congelan el reloj del proceso en UTC, como en Cloud Run:

services/api/app/availability_engine/tests/test_timezones.py
from __future__ import annotations

from datetime import datetime

import pytest
from freezegun import freeze_time

from services.api.app.availability_engine.timezones import now_local, now_local_aware, tz_for


@pytest.mark.parametrize(
    "zona, pais, esperado",
    [
        ("America/Lima", "PE", "America/Lima"),
        (None, "CL", "America/Santiago"),  # fallback por país
        ("", "EC", "America/Guayaquil"),  # cadena vacía = falta
        ("America/Cancun", "MX", "America/Cancun"),  # Ubigeo gana al país
        (None, "MX", "America/Mexico_City"),  # sin Ubigeo, México cae a la capital
        (None, None, "America/Lima"),  # default
        (None, "XX", "America/Lima"),  # país desconocido → default
    ],
)
def test_tz_for(zona: str | None, pais: str | None, esperado: str):
    assert tz_for(zona, pais) == esperado


@freeze_time("2026-08-18 00:30:00")  # UTC — en Lima son las 19:30 del 17
def test_now_local_es_naive_y_en_la_pared_de_lima():
    ahora = now_local("America/Lima")
    assert ahora.tzinfo is None
    assert ahora == datetime(2026, 8, 17, 19, 30)


@freeze_time("2026-08-18 00:30:00")
def test_now_local_aware_lleva_la_zona():
    ahora = now_local_aware(None, "CL")
    assert ahora.tzinfo is not None
    assert (ahora.hour, ahora.day) == (20, 17)  # Santiago en agosto: UTC-4
    offset = ahora.utcoffset()
    assert offset is not None and offset.total_seconds() == -4 * 3600
PYTHONPATH=. pytest services/api/app/availability_engine/tests/test_timezones.py -q
Deberías ver
.........                                                                [100%]
9 passed in 0.03s
Naive vs aware, en una frase. Un datetime naive es una hora de pared sin decir de dónde ("19:30"). Uno aware lleva tzinfo ("19:30 America/Lima" = un instante concreto del universo). datetime.now() a secas devuelve un naive con la hora del reloj del proceso: en tu laptop, la de tu laptop; en Cloud Run, UTC. Ese es todo el bug.

Paso 3 La trampa de las 19:00, en un test

Antes de escribir reglas, deja el bug reproducido para siempre. Crea el módulo disponibilidad/ con una primera función pura: es_hoy(local, fecha, ahora). Recibe ahora por parámetro; si viene aware, lo convierte a la zona del local; si viene naive, asume que ya está en la pared del local (que es lo que devuelve now_local).

services/api/app/disponibilidad/rules.py (primera parte)
"""Reglas puras de disponibilidad. Reciben `ahora` explícito: nunca lo calculan.

Guideline 2 de m-b-core: cada regla es una función pura con entradas explícitas.
Guideline 6: la zona es la del restaurante. Si una función necesita "ahora",
lo recibe por parámetro; el que la llama decide (y el test también).
"""

from __future__ import annotations

from datetime import date, datetime, timedelta

from services.api.app.availability_engine.models import MINUTES_PER_SLOT, Horario, Local
from services.api.app.availability_engine.timezones import tz_for

ANTICIPACION_MINUTOS = 60  # una reserva necesita al menos 1 h de anticipación


def es_hoy(local: Local, fecha: date, ahora: datetime) -> bool:
    """¿`fecha` es "hoy" para este restaurante?

    `ahora` debe ser un datetime NAIVE en la pared del restaurante (now_local)
    o AWARE en cualquier zona: si es aware lo convertimos a la zona del local.
    """
    if ahora.tzinfo is not None:
        from zoneinfo import ZoneInfo

        ahora = ahora.astimezone(ZoneInfo(tz_for(local.zona_horaria, local.pais)))
    return ahora.date() == fecha

Y los tres tests que cuentan la historia completa: la hora de Lima, el mismo instante visto desde UTC (que cree que es mañana), y lo que pasa si alguien pasa un datetime.now() naive desde un servidor en UTC como si fuera hora de Lima. El tercero es el bug del incidente, reproducido a propósito y con False como resultado esperado: el test documenta el fallo.

services/api/app/disponibilidad/tests/test_rules.py (primera parte)
"""Tests puros: `ahora` se inyecta. Nada de datetime.now() dentro del test."""

from __future__ import annotations

from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo

import pytest

from services.api.app.availability_engine.models import Horario, Local
from services.api.app.disponibilidad.rules import es_hoy, slots_disponibles

LIMA = Local(id=11, nombre="Lima", pais="PE", zona_horaria="America/Lima")
SANTIAGO = Local(id=873, nombre="Santiago", pais="CL", zona_horaria=None)  # fallback por país
QUITO = Local(id=2246, nombre="Quito", pais="EC", zona_horaria="America/Guayaquil")
CANCUN = Local(id=9001, nombre="Cancún", pais="MX", zona_horaria="America/Cancun")
CDMX = Local(id=9002, nombre="CDMX", pais="MX", zona_horaria=None)  # país → Mexico_City


# --- la trampa de las 19:00 -------------------------------------------------
# 2026-08-17 19:30 en Lima  ==  2026-08-18 00:30 en UTC.
# Un servidor en UTC que pregunte "¿qué día es hoy?" con datetime.now() dice 18.
LIMA_1930 = datetime(2026, 8, 17, 19, 30, tzinfo=ZoneInfo("America/Lima"))


def test_a_las_1930_de_lima_todavia_es_17_para_lima():
    assert es_hoy(LIMA, date(2026, 8, 17), LIMA_1930) is True


def test_el_mismo_instante_en_utc_ya_es_18_pero_es_hoy_convierte():
    mismo_instante_utc = LIMA_1930.astimezone(UTC)
    assert mismo_instante_utc.date() == date(2026, 8, 18)  # UTC "cree" que es mañana
    assert es_hoy(LIMA, date(2026, 8, 17), mismo_instante_utc) is True  # la regla no


def test_naive_utc_como_si_fuera_lima_da_la_respuesta_equivocada():
    # Lo que pasa en el motor si alguien pasa datetime.now() naive desde Cloud Run (UTC):
    naive_utc = LIMA_1930.astimezone(UTC).replace(tzinfo=None)  # 2026-08-18 00:30, sin zona
    assert es_hoy(LIMA, date(2026, 8, 17), naive_utc) is False  # BUG reproducido a propósito

Corre solo estos tres (todavía no existe slots_disponibles; comenta ese import un momento o sigue al paso 4 y córrelos todos juntos):

PYTHONPATH=. pytest services/api/app/disponibilidad -q -k "1930 or utc"
Deberías ver
...                                                                      [100%]
3 passed in 0.02s
Léelo dos veces. El mismo instante físico es "17 de agosto" para el restaurante y "18 de agosto" para el servidor. Ninguno miente: son marcos distintos. El bug aparece cuando se mezcla el marco del servidor con la pregunta del restaurante. En m-b-core, engine.compute() tiene if now is None: now = datetime.now() como default (engine.py:601 en prod): mientras todas las rutas del motor pasen now=now_local(...), funciona; el día que una no lo pase (listings/routes.py:1083 ya no lo pasa), a las 19:00 de Lima el motor cree que es mañana.

Paso 4 Reglas con "ahora" explícito: slots_disponibles

La regla de negocio de verdad: slots de 15 minutos entre apertura y cierre; si la fecha es "hoy" para el restaurante, se descartan los slots que ya pasaron o que no llegan a la anticipación mínima. Es la misma decisión que documenta el CLAUDE.md de m-b-core: "Anticipacion and HoraMaximaReservar only apply to today". Y de nuevo: ahora entra por parámetro, sin default.

services/api/app/disponibilidad/rules.py (segunda parte, al final)
def _parse_hhmm(hhmm: str, fecha: date) -> datetime:
    h, m = int(hhmm[:2]), int(hhmm[3:5])
    return datetime(fecha.year, fecha.month, fecha.day, h, m)


def slots_disponibles(local: Local, horario: Horario, ahora: datetime) -> list[str]:
    """Slots 'HH:MM' cada MINUTES_PER_SLOT entre apertura y cierre.

    Si `horario.fecha` es hoy para el restaurante, se descartan los slots que
    ya pasaron o que no llegan a ANTICIPACION_MINUTOS. Fechas futuras: todos.
    (Decisión de m-b-core: Anticipacion solo aplica a "hoy".)
    """
    if ahora.tzinfo is not None:
        from zoneinfo import ZoneInfo

        ahora = ahora.astimezone(ZoneInfo(tz_for(local.zona_horaria, local.pais))).replace(
            tzinfo=None
        )
    inicio = _parse_hhmm(horario.apertura, horario.fecha)
    fin = _parse_hhmm(horario.cierre, horario.fecha)
    hoy = ahora.date() == horario.fecha
    limite = ahora + timedelta(minutes=ANTICIPACION_MINUTOS)

    slots: list[str] = []
    t = inicio
    while t < fin:
        if not hoy or t >= limite:
            slots.append(t.strftime("%H:%M"))
        t += timedelta(minutes=MINUTES_PER_SLOT)
    return slots

Horario es una dataclass en models.py (apertura, cierre como 'HH:MM' en hora local, fecha). Los tests: fecha futura devuelve todo; hoy descarta lo que no llega; y si ahora viene aware en UTC, se convierte a la pared del local y da lo mismo.

services/api/app/disponibilidad/tests/test_rules.py (agregar)
HORARIO_HOY = Horario(apertura="12:00", cierre="14:00", fecha=date(2026, 8, 17))


def test_futuro_devuelve_todos_los_slots():
    ahora = datetime(2026, 8, 10, 9, 0)  # naive, pared de Lima, una semana antes
    slots = slots_disponibles(LIMA, HORARIO_HOY, ahora)
    assert slots == ["12:00", "12:15", "12:30", "12:45", "13:00", "13:15", "13:30", "13:45"]


def test_hoy_descarta_lo_que_no_llega_a_la_anticipacion():
    ahora = datetime(2026, 8, 17, 12, 20)  # naive, pared de Lima
    slots = slots_disponibles(LIMA, HORARIO_HOY, ahora)
    assert slots == ["13:30", "13:45"]  # 13:20 es el límite (12:20 + 60 min)


def test_hoy_con_ahora_aware_en_utc_se_convierte_a_la_pared_del_local():
    ahora_utc = datetime(2026, 8, 17, 17, 20, tzinfo=UTC)  # = 12:20 Lima
    assert slots_disponibles(LIMA, HORARIO_HOY, ahora_utc) == ["13:30", "13:45"]
Fíjate en lo que no hay. Ni datetime.now(), ni freeze_time: cuando ahora es un parámetro, el test es determinista sin trucos, y la ruta que llama a la regla es la única que decide de dónde sale el reloj (now_local(local.zona_horaria, local.pais)). Así están las reglas de availability_engine/rules.py: apply_*(engine, sa, target_date) → None, entradas explícitas, sin estado global.

Paso 5 Un instante, cinco restaurantes (y por qué no "por país")

Un solo instante UTC y cinco locales. Para dos de ellos ya es mañana. Y dos son de México con zonas distintas: si el mapa fuera solo por país, Cancún estaría mal.

services/api/app/disponibilidad/tests/test_rules.py (agregar)
# --- varias zonas, un instante ------------------------------------------------
INSTANTE = datetime(2026, 8, 18, 4, 30, tzinfo=UTC)  # 23:30 Lima/Quito, 00:30 Santiago (invierno CL)


@pytest.mark.parametrize(
    "local, esperado",
    [
        (LIMA, date(2026, 8, 17)),  # UTC-5
        (QUITO, date(2026, 8, 17)),  # UTC-5
        (SANTIAGO, date(2026, 8, 18)),  # UTC-4 en agosto → ya es mañana
        (CANCUN, date(2026, 8, 17)),  # UTC-5 fijo (Quintana Roo, sin DST)
        (CDMX, date(2026, 8, 17)),  # UTC-6
    ],
)
def test_que_dia_es_depende_del_restaurante(local: Local, esperado: date):
    assert es_hoy(local, esperado, INSTANTE) is True


# --- Santiago: verano vs invierno -----------------------------------------------
@pytest.mark.parametrize(
    "instante_utc, hora_pared",
    [
        (datetime(2026, 8, 17, 12, 0, tzinfo=UTC), "08:00"),  # agosto: UTC-4
        (datetime(2026, 12, 17, 12, 0, tzinfo=UTC), "09:00"),  # diciembre: UTC-3 (DST)
    ],
)
def test_santiago_cambia_de_offset_por_horario_de_verano(instante_utc: datetime, hora_pared: str):
    local_dt = instante_utc.astimezone(ZoneInfo("America/Santiago"))
    assert local_dt.strftime("%H:%M") == hora_pared
PYTHONPATH=. pytest -v
Deberías ver (recortado)
services/api/app/availability_engine/tests/test_timezones.py::test_tz_for[America/Lima-PE-America/Lima] PASSED
…
services/api/app/disponibilidad/tests/test_rules.py::test_naive_utc_como_si_fuera_lima_da_la_respuesta_equivocada PASSED
services/api/app/disponibilidad/tests/test_rules.py::test_que_dia_es_depende_del_restaurante[local2-esperado2] PASSED
services/api/app/disponibilidad/tests/test_rules.py::test_santiago_cambia_de_offset_por_horario_de_verano[instante_utc1-09:00] PASSED
…
============================== 23 passed in 0.04s ==============================
Santiago no es "UTC−4". Es UTC−4 en invierno y UTC−3 en verano (y el gobierno chileno ha movido las fechas del cambio varias veces). Por eso "restar cuatro horas" está mal la mitad del año, y por eso ZoneInfo con tzdata actualizado, no aritmética. Es también por lo que tzdata va en requirements/base.txt con versión: la base de zonas horarias es una dependencia más.

Paso 6 utcnow() − 5 h, y por qué comparar aware con naive debe explotar

Dos demostraciones en el intérprete, con salida real. Primero la línea 891 de libro/reservation_detail.py ("approximate by shifting UTC to Lima (−5h)") aplicada a un restaurante de Santiago en diciembre:

python - <<'PY'
from datetime import datetime, timedelta, UTC
from zoneinfo import ZoneInfo
instante = datetime(2026, 12, 17, 23, 30, tzinfo=UTC)
a_ojo = instante.replace(tzinfo=None) + timedelta(hours=-5)
bien = instante.astimezone(ZoneInfo("America/Santiago"))
print("UTC        :", instante)
print("utcnow()-5h:", a_ojo, "(dice 18:30 del 17)")
print("Santiago   :", bien.strftime("%Y-%m-%d %H:%M %Z%z"), "(en verano son las 20:30)")
PY
Deberías ver
UTC        : 2026-12-17 23:30:00+00:00
utcnow()-5h: 2026-12-17 18:30:00 (dice 18:30 del 17)
Santiago   : 2026-12-17 20:30 -03-0300 (en verano son las 20:30)

Dos horas de error para un restaurante chileno en diciembre. Y utcnow() además está deprecado desde Python 3.12 (m-b-core no lo ve porque pytest.ini silencia todos los DeprecationWarning; en la práctica 8 lo arreglas):

python -W error -c "from datetime import datetime; datetime.utcnow()"
Deberías ver
DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).

Segundo: Python se niega a comparar un aware con un naive. Es una de las mejores decisiones del lenguaje: el error salta donde está la mezcla, no tres funciones más lejos. Déjalo como test para que nadie "arregle" el TypeError quitando la zona:

services/api/app/disponibilidad/tests/test_rules.py (agregar al final)
def test_comparar_aware_con_naive_explota_y_eso_es_bueno():
    aware = datetime(2026, 8, 17, 12, 0, tzinfo=ZoneInfo("America/Lima"))
    naive = datetime(2026, 8, 17, 12, 0)
    with pytest.raises(TypeError):
        _ = aware < naive
Entonces, ¿naive o aware en m-b-core? El motor compara contra Reserva.Horario y ProgramacionReserva.HoraInicio, que el legacy guarda como hora de pared sin zona. Por eso now_local() devuelve naive: naive contra naive, en el mismo marco (la pared del restaurante). El coste es que un datetime.now() UTC naive "encaja" sin TypeError y da resultados equivocados (paso 3). La regla práctica de la casa: naive solo en el borde con el legacy y solo si viene de now_local(); todo lo demás aware (guardar, serializar, calcular diferencias, comparar entre locales). Cuando dudes: aware, y convierte con .astimezone(ZoneInfo(tz_for(...))) justo antes de tocar el legacy.

Paso 7 Que lo vigile el linter: ruff DTZ

Todo lo anterior son buenas intenciones. Lo que las hace cumplir es una regla del linter. Ruff trae flake8-datetimez (DTZ): marca datetime.now() sin tz, utcnow(), date.today() y compañía. m-b-core no lo tiene en select (por eso los tres sitios del vicio siguen ahí). Actívalo:

pyproject.toml (modificar)
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP", "DTZ"]   # ← DTZ nuevo

Escribe a propósito lo que no se debe, para verlo marcado (borra el archivo después):

services/api/app/disponibilidad/_como_no.py (temporal)
"""Ejemplo a propósito de lo que ruff DTZ debe atrapar. Se borra después."""

from datetime import date, datetime, timedelta


def hoy_del_servidor() -> date:
    return date.today()  # ¿hoy dónde? En Cloud Run: en Londres.


def ahora_del_servidor() -> datetime:
    return datetime.now()  # naive, UTC en prod, tu laptop en dev


def ahora_lima_a_ojo() -> datetime:
    return datetime.utcnow() + timedelta(hours=-5)  # libro/reservation_detail.py:891
ruff check services/api/app/disponibilidad/_como_no.py
Deberías ver
services/api/app/disponibilidad/_como_no.py:7:12: DTZ011 `datetime.date.today()` used
  = help: Use `datetime.datetime.now(tz=...).date()` instead
services/api/app/disponibilidad/_como_no.py:11:12: DTZ005 `datetime.datetime.now()` called without a `tz` argument
  = help: Pass a `datetime.timezone` object to the `tz` parameter
services/api/app/disponibilidad/_como_no.py:15:12: DTZ003 `datetime.datetime.utcnow()` used
  = help: Use `datetime.datetime.now(tz=...)` instead
Found 3 errors.
rm services/api/app/disponibilidad/_como_no.py
ruff check . && ruff format --check . && PYTHONPATH=. mypy services/api/app/availability_engine services/api/app/disponibilidad shared --explicit-package-bases
Deberías ver
All checks passed!
12 files already formatted
Success: no issues found in 11 source files

Fíjate en que DTZ no se queja de datetime.now(tz).replace(tzinfo=None) en now_local: el now() lleva zona; el .replace es una decisión explícita. Justo la frontera que queremos: el linter atrapa el olvido, no la decisión. Cierra con un commit:

git add -A && git commit -m "feat(disponibilidad): reglas con ahora explícito, tz por restaurante y ruff DTZ"

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

Lo que escribisteEn m-b-coreNota
tz_for(zona_horaria, pais)services/api/app/availability_engine/timezones.py:38-44Misma firma y prioridad. El mapa país→zona vive ahí (_COUNTRY_TZ) y en shared/timezone.py (COUNTRY_TIMEZONES): dos copias, una deuda pequeña que ya está en el informe.
now_local(...) naivetimezones.py:47-50Devuelve naive a propósito para comparar con Reserva.Horario. La versión aware no existe en m-b-core; si la necesitas, propónla en shared/timezone.py.
today_for_venue(zona, pais)shared/timezone.py:61-75Idéntica. La usa listings/routes.py:855-859 con un comentario que explica por qué no date.today()… y 170 líneas después el mismo archivo usa date.today() (:1032). Ese es el tipo de cosa que DTZ atrapa.
es_hoy, slots_disponibles con ahora explícitoavailability_engine/rules.py (apply_*), routes.py:240,292,451 pasan now=now_local(...)Guideline 2: reglas puras. La trampa: engine.compute() tiene now=None → datetime.now() como default (engine.py:601). Tu regla no tiene default; es la diferencia.
Test "naive UTC como si fuera Lima → False"No existe. Debería.Un buen primer PR real: agregar ese test al motor y quitar el default naive de compute().
utcnow() + timedelta(hours=-5) como contraejemplolibro/reservation_detail.py:891Está en el core nuevo. Ahora sabes exactamente qué rompe (Santiago en verano: dos horas) y con qué reemplazarlo (now_local(local.zona_horaria, local.pais)).
Guideline 6CLAUDE.md §6 "Per-restaurant timezone""Use Ubigeo.ZonaHoraria, never assume by country. Fall back to country only when ZonaHoraria is NULL." Tus tests de Cancún vs CDMX son esa frase hecha código.

Carbon y datetime, lado a lado

Laravel (lo que ya haces)
// zona del local, no del servidor
$ahora = Carbon::now($local->timezone);
$hoy   = Carbon::today($local->timezone);
// convertir un instante a la zona del local
$enLima = $utc->copy()->setTimezone('America/Lima');
// NUNCA:  Carbon::now()   date('Y-m-d')   ->subHours(5)
m-b-core
# zona del local, no del servidor
ahora = now_local(local.zona_horaria, local.pais)          # naive, pared del local
hoy   = today_for_venue(local.zona_horaria, local.pais)
# convertir un instante aware a la zona del local
en_lima = instante_utc.astimezone(ZoneInfo(tz_for(...)))
# NUNCA: datetime.now()   date.today()   utcnow()   timedelta(hours=-5)

Una diferencia real: en PHP Carbon siempre lleva zona (aunque sea la del servidor); en Python el naive existe y es el default de datetime.now(). Por eso en Python el linter importa más. Y una igualdad: en los dos, "restar cinco horas" está mal la mitad del año en Chile.

Con Claude

Esta práctica es de las que conviene escribir a mano una vez (el modelo mental naive/aware no se aprende leyendo). Después, Claude sirve para dos cosas: encontrar los sitios sospechosos y proponer el reemplazo.

Así noArregla los problemas de zona horaria del motor.Va a cambiar cinco cosas a la vez, quizá quitando el naive que el legacy necesita.
Así síEn services/api/app/ busca todos los datetime.now() sin tz, utcnow() y date.today(). Para cada uno dime: ¿qué compara?, ¿es naive del legacy o debería ser aware?, y qué reemplazo propones con now_local/today_for_venue/now(tz). No cambies nada todavía.Inventario primero, decisión tuya después.
Así noMe sale TypeError: can't compare offset-naive and offset-aware datetimes, arréglalo.Lo va a "arreglar" con .replace(tzinfo=None) y esconder el bug.
Así síEste TypeError me dice que estoy mezclando un aware con un naive en slots_disponibles. ¿Cuál de los dos lados está mal según la regla de la casa (naive solo en el borde con el legacy)? Explícamelo antes de proponer código.El error es información; primero se entiende, luego se decide.

Ejercicio con Claude, ya con esta práctica hecha: pídele "hazme cinco preguntas de criterio sobre zonas horarias en m-b-core (naive/aware, DST, Ubigeo vs país, DTZ) y corrige mis respuestas con dureza".

Si algo falla

zoneinfo._common.ZoneInfoNotFoundError: 'No time zone found with key America/Santiago'

Falta la base de datos de zonas. En macOS/Linux suele venir del sistema; en python:3.12-slim y en Windows no. Por eso tzdata está en requirements/base.txt: pip install -r requirements/local.txt y vuelve a probar. Es una dependencia de producción, no de desarrollo.

TypeError: can't compare offset-naive and offset-aware datetimes

Estás mezclando marcos. Es la señal correcta, no un bug de Python. Decide cuál lado debería ser aware (casi siempre el que no viene del legacy) y convierte con .astimezone(ZoneInfo(...)). Lo que NO se hace: .replace(tzinfo=None) "para que compile" fuera del borde con el legacy.

El test de las 19:00 pasa en mi laptop y falla en CI (o al revés)

Tienes un datetime.now() escondido en el test o en la regla, y tu laptop está en hora de Lima mientras CI está en UTC. Los tests de esta práctica no llaman al reloj: inyectan ahora o usan freeze_time. Busca now() en el diff.

freeze_time no afecta a now_local

freezegun parchea datetime.datetime; si en el módulo importaste from datetime import datetime funciona (así está en timezones.py). Si importaste el módulo entero (import datetime) también. Lo que no congela es un reloj que ya se leyó a nivel de módulo en tiempo de import (una constante AHORA = datetime.now()): eso es un bug de diseño, no de freezegun.

Santiago da 08:00 en un test y 09:00 en otro con la misma hora UTC

Es correcto: horario de verano. Chile está en UTC−4 en invierno (agosto) y UTC−3 en verano (diciembre). Si te sale otra cosa, tu tzdata está viejo (pip install -U tzdata) o estás restando horas a mano en vez de usar ZoneInfo.

ruff no marca mi datetime.now()

Revisa que "DTZ" esté en [tool.ruff.lint] select del pyproject.toml (no en extend-select de otro archivo) y que estés corriendo el ruff del venv (.venv/bin/ruff --version). Y recuerda que datetime.now(tz) con zona no se marca: eso es lo que queremos.

mypy: Source file found twice under different module names

Igual que en m-b-core: services/ y services/api/ no tienen __init__.py a propósito, así que mypy necesita --explicit-package-bases (está en el Makefile y en el ci.yml real: .github/workflows/ci.yml:38).

Listo cuando

  • Los 23 tests pasan; ruff check (con DTZ), ruff format --check y mypy en verde.
  • Puedes explicar con tus palabras por qué now_local() de m-b-core devuelve naive y cuándo tú usarías aware.
  • El test test_naive_utc_como_si_fuera_lima_da_la_respuesta_equivocada existe y entiendes por qué su resultado esperado es False.
  • Sabes decir qué zona tiene un restaurante de Cancún y por qué "por país" lo rompe.
  • Ninguna función de tu módulo llama a datetime.now(): el "ahora" entra por parámetro y lo decide la ruta.
  • Escribiste en tu DECISIONES.md la regla: naive solo en el borde con el legacy y solo desde now_local(); todo lo demás aware.

Siguiente

En la Práctica 7 · Errores, logging y tareas idempotentes las excepciones del dominio se convierten en respuestas HTTP con código, los logs llevan extra={} en vez de f-strings, y el worker recibe una tarea que puede correr dos veces sin mandar dos avisos: la disponibilidad que calculaste aquí va a necesitar el "ahora" del restaurante también allá.

Si te quedaste con ganas: un primer PR real a m-b-core podría ser exactamente el test del paso 3 aplicado a engine.compute(), y quitar el default now=None. Es chico, se entiende, y cierra una puerta que hoy está abierta.