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
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 verAmerica/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.pyfrom __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
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 verUTC : 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 verDeprecationWarning: 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 verservices/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 verAll 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"
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".