Один момент, два часовых пояса: контекст сериализации Pydantic

Сервис хранит время события в UTC. Оператор просит выгрузку по московскому времени, а интеграция ожидает UTC. Изменять поле объекта перед каждым экспортом неудобно: две параллельные выгрузки могут начать зависеть от последнего присваивания. Лучше оставить один момент времени в модели и передать формат представления в сериализацию.

Контекст относится к одному вызову экспорта

model_dump принимает context, доступный внутри field_serializer через info.context. В нём можно передать выбранный часовой пояс. Сериализатор преобразует datetime через astimezone и возвращает строку ISO 8601 со смещением. Исходный объект остаётся прежним. Используем when_used="json", чтобы обычный Python-dump продолжал содержать datetime.

Часовой пояс для интерфейса нужно выбрать явно: из настройки пользователя или параметра запроса с проверкой. Отсутствующий контекст в нашем примере означает UTC. Не меняйте глобальную переменную TZ перед каждым запросом: это настройка процесса, а представление требуется конкретному экспорту. Такой подход также упрощает тест, потому что нужная зона передаётся непосредственно в вызов.

Два результата без изменения модели

Установите Pydantic v2. Для воспроизводимого теста используются фиксированные смещения timezone; переходы летнего времени здесь не требуются. В приложении с названиями зон можно передавать ZoneInfo, учитывая наличие базы часовых поясов в окружении.

from datetime import datetime, timezone, timedelta
from pydantic import BaseModel, AwareDatetime, FieldSerializationInfo, field_serializer, ValidationError

class Event(BaseModel):
    occurred_at: AwareDatetime

    @field_serializer("occurred_at", when_used="json")
    def render_time(self, value: datetime, info: FieldSerializationInfo) -> str:
        context = info.context or {}
        zone = context.get("timezone", timezone.utc)
        return value.astimezone(zone).isoformat()

event = Event(occurred_at=datetime(2026, 1, 15, 9, 0, tzinfo=timezone.utc))
utc = event.model_dump(mode="json")
moscow = event.model_dump(mode="json", context={
    "timezone": timezone(timedelta(hours=3))})
assert utc == {"occurred_at": "2026-01-15T09:00:00+00:00"}
assert moscow == {"occurred_at": "2026-01-15T12:00:00+03:00"}
assert datetime.fromisoformat(utc["occurred_at"]) == datetime.fromisoformat(moscow["occurred_at"])
assert event.occurred_at.hour == 9
assert isinstance(event.model_dump()["occurred_at"], datetime)
try:
    Event(occurred_at=datetime(2026, 1, 15, 9))
except ValidationError:
    print({"naive_datetime": "rejected"})
else:
    raise AssertionError("naive datetime accepted")
print({"utc": utc, "moscow": moscow})

Подключение к HTTP-ответу

Если сериализация зависит от запроса, передайте контекст в конкретное место построения ответа. Обычное возвращение модели из маршрута не передаёт в её serializer произвольную вашу переменную автоматически. Можно построить JSON-совместимое содержимое через model_dump(mode="json", context=...) и вернуть подготовленный JSONResponse. При этом описание схемы и тест ответа нужно поддерживать явно.

Альтернатива — всегда возвращать UTC API-клиенту, а местное время показывать на frontend. Для универсального API это часто проще. Контекст полезен, когда сервер действительно отвечает за готовую выгрузку, письмо или документ. Выбирайте преобразование там, где находится обязанность представить время пользователю.

Строка должна сохранять смещение или обозначение UTC. Просто форматировать часы через strftime без зоны опасно: получателю придётся угадывать, какой момент имелся в виду. В примере две разные строки сравниваются как datetime и оказываются одним моментом. Такой тест обнаружит случайный replace(tzinfo=...) вместо правильного astimezone.

Для именованных зон добавьте проверку зимней и летней даты. Не подменяйте ZoneInfo постоянным смещением, если правила зоны допускают сезонный переход. Контекст хранит объект выбранной зоны, а библиотека дат выполняет преобразование по дате события. Внешний ввод с неизвестным именем зоны лучше отклонять до начала сериализации.

В статье о времени без зоны решается приём входного datetime. Здесь время уже однозначно известно, а меняется только его представление для конкретного экспорта.

Продолжить на практике

Отделите хранение события от форматирования и покройте оба представления тестами. В продвинутом Python есть композиция, типизация и pytest; они помогут оформить экспорт без глобального изменяемого состояния. Откройте программу продвинутого Python.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

    Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.