воскресенье, 27 февраля 2011 г.

Питон: времена, даты и временные зоны

Сегодня поговорим о дате-времени.

При этом я не хочу отдельно останавливаться на модуле time - слишком он низкоуровневый.

Эта статья будет почти исключительно посвящена модулю datetime, предоставляющему довольно красивый и понятный интерфейс.

Давайте посмотрим на элементарный пример:

>>> from datetime import *
>>> dt = datetime.now()

Что может быть проще?

Правильно ли так писать? Ответ будет довольно неожиданным: когда как...

Дело в том, что в программах, как бы это парадоксально не звучало, одновременно существуют два типа времени, которые логически не перекрываются.

Назовем их относительным и абсолютным временем.

Относительное время

Этот тип времени никогда не пересекает границ программы, не сохраняется в базе данных и не передается по сети.

Используется для разных целей: измерения временных интервалов, общения с пользователем и так далее.

У относительного времени (naive в английской терминологии) нет информации о временной зоне (timezone). Наш простейший пример создавал именно такое время.

Большинство используемых в небрежно составленной программе времен - относительные. И это далеко не всегда корректно.

Абсолютное время

В противовес относительному обладает временной зоной, которая вдобавок не пустая.

В английском для таких времен применяют термин aware. Перевод на русский звучит как осведомленный, что мне решительно не нравится. Пусть так и остается абсолютным временем.

Временные зоны

Позвольте немного подробней рассказать о временных зонах.

Это наши привычные смещения относительно времени по Гринвичу. Например, в моём Киеве локальное время отличается на +2 часа (летом +3).

Объекты datetime и time могут принимать необязательный парамер по имени tzinfo.

Этот параметр должен быть или None или экземпляром класса, унаследованного от базового абстрактного класса tzinfo.

Я сейчас не хочу подробно останавливаться на том, как правильно унаследоваться от tzinfo и что нужно переопределить. Достаточно знать, что объекты временных зон существуют.

Теория работы с абсолютным временем

Когда пишем время в базу данных или пересылаем с одной машины на другую - во избежание проблем следует пользоваться абсолютным временем, а еще лучше приводить время к Гринвичу (так называемому UTC).

Дело в том, что у разных машин могут быть разные временные зоны, а Киевское время, например, отличается от Московского на один час. В результате одно и то же, казалось бы, время будет означать в Москве не совсем то, что оно означает в Киеве. В Москве может быть уже "завтра", когда у меня еще "сегодня".

Чтобы окончательно всё запутать, существует летнее время. Дата перехода на него отличается от страны к стране. Если в Украине летнее время начинается в последнее воскресенье марта, то в Бразилии, насколько я помню, оно заканчивается в последнее воскресенье февраля (при условии что эта дата не совпадает с их праздником Карнавала).

В общем, без справочника не разберешься, и разница между локальным временем и UTC "на сегодня" завтра может измениться в ту или иную сторону.

Если же во всех внешних коммуникациях используется UTC (которое не имеет летнего времени, между прочим) - всё получается однозначно.

К сожалению, об абсолютных временах и "правиле UTC" очень часто забывают при разработке.

В результате к проблеме возвращаются, когда уже есть, например, довольно большая база данных и немаленькие программы, работающие с ней.

В таких условиях осуществить миграцию на "правильный" способ уже практически нереально.

Особенности реализации относительного и абсолютного времени в Питоне

В целях окончательного усложнения жизни программиста Питон сам по себе добавляет несколько интересных особенностей.

По умолчанию объект datetime.datetime (всё сказанное применимо и к datetime.time) создается как относительное время, то есть без временной зоны.

Более того, существует два метода получить текущее время: datetime.now() и datetime.utcnow(). Полученные значения, конечно, различаются на действующую сейчас временную разницу. При этом вы не можете программно понять, где время в UTC а где - локальное.

Базы данных вносят дополнительные оттенки. Некоторые могут хранить времена в абсолютном формате, другие - нет. Впрочем, это не важно - даже если конкретная база данных поддерживает абсолютное время и библиотека для работы с этой базой умеет понимать временные зоны - используемая вами объектно-реляционная надстройка (ORM) скорее всего это ценное умение игнорирует.

Поэтому единственный надежный способ работы с базами данных - использовать относительное время, явно преобразуя его в UTC.

На базах данных странности не кончаются.

Дело в том, что только кажется: в Питоне есть лишь один datetime.datetime. Хотя технически все объекты класса datetime.datetime используют этот единственный тип, на самом деле концептуально абсолютные времена (с указанной зоной) никак не совместимы с относительными.

Т.е. вы не можете сравнить абсолютное и относительное время, вычесть из одного другое и так далее - на любую подобную попытку получите TypeError.

И, чтобы окончательно свести с ума; забить, так сказать, последний гвоздь в крышку гроба - стандартная питоновская библиотека не имеет временных зон.

Она содержит абстрактный класс tzinfo - и ни одной его конкретной реализации.

Лишь в Python 3.2 появилась зона datetime.utc. Локальной зоны всё ещё нет.

После всего перечисленного неудивительно, что повсеместно используется относительное время в его худшем варианте - локальное для конкретной машины, без информации о том, какая зона здесь установлена.

При передаче этого относительного времени на другую машину получаем неопределенность, чреватую неожиданностями. Ну, дальше вы поняли...

Абсолютные времена, pytz и dateutil

Для более или менее комфортной жизни в этом сумасшедшем доме я рекомендую следующее.

Работайте с временами в базе данных только как с относительными (никуда не денешься), но храните их исключительно в UTC.

При обработке сразу же добавляйте временную зону UTC в явном виде. Для ORM процесс можно автоматизировать, унаследовавшись от существующего в вашей ORM описания колонки (поля) DateTime и добавив в преобразователи явное приведение временных зон. То же самое относится и, например, к библиотекам GUI.

Везде в программе используйте только абсолютные времена, особенно если у этих времен именно абсолютный контекст.

Вообще-то говоря, к примеру, измерить время выполнения алгоритма можно и применяя относительное время.

Но работа исключительно с абсолютными временами вносит полезную строгость и дисциплину. Раз уж вам хоть где-то потребовались абсолютные времена - применяйте их везде без компромиссов.

Операции с абсолютными временами безопасны, даже если они относятся к разным временным зонам - Питон всё учитывает.

Остается вопрос, где эти временные зоны брать.

Существует прекрасная библиотека dateutil.

Она много чего умеет, неплохо расширяя стандартную datetime.

Что касается временных зон:

>>> from dateutil.tz import tzutc, tzlocal
>>> tzutc = tzutc()
>>> tzlocal = tzlocal()

Если вам требуется получить зону по имени - используйте библиотеку pytz.

>>> import pytz
>>> tzkiev = pytz.timezone('Europe/Kiev')
>>> print tzkiev
Europe/Kiev

Классы временных зон для dateutil и pytz немного отличаются списком доступных методов. Вы можете прочесть все детали в документации. Для этого изложения важно то, что они оба поддерживают интерфейс, требуемый для datetime.tzinfo.

Теперь минимальный набор операций.

Получение пресловутого текущего времени:

>>> now = datetime.now(tzlocal)
>>> print now
2011-02-25 11:52:59.887890+02:00

Преобразование в UTC:

>>> utc = now.astimezone(tzutc)
>>> print utc
2011-02-24 09:52:59.887890+00:00

Объект utc можно класть в базу данных или пересылать по сети - он преобразован с учетом смещения.

Отбросить временную зону (если, например, база данных не игнорирует эту информацию, а требует явного относительного времени):

>>> naive_utc = utc.replace(tzinfo=None)
>>> print naive_utc
2011-02-24 09:52:59.887890

Добавить временную зону к объекту, полученному из базы данных

>>> utc2 = naive_utc.replace(tzinfo=tzutc)
>>> print utc2
2011-02-24 09:52:59.887890+00:00

Преобразовать абсолютное UTC время в абсолютное локальное

>>> local_dt = utc.astimezone(tzlocal)
>>> print local_dt
2011-02-25 11:52:59.887890+02:00

Те же операции следует проводить и с GUI, переводя в локальное относительное время и обратно, если GUI библиотека не поддерживает временные зоны.

Итоги

  • Старайтесь работать с абсолютными временами.

  • Для общения с внешним миром предпочитайте UTC.

  • Если по каким-то причинам вам приходится иметь дело с относительным временем - сокращайте такие моменты до минимума, возвращаясь к абсолютным временам сразу же как только возможно.

  • Помните: ввести абсолютные времена на разросшийся и уже массово использующийся проект стоит чрезвычайно дорого.

    Это касается не только переделки кода - зачастую требуется еще и поправить все записи с временами в базе данных (при мысли о том, что делать с резервными копиями мне становится нехорошо) и, скажем, одномоментно обновить весь сопутствующий софт у ваших клиентов.

    Гораздо дешевле делать "правильно" с самого начала.

Предупрежден - значит вооружен!

Удачного вам плаванья и семи футов под килем!

Ссылки

16 комментариев:

  1. Хорошая статья, Андрей!

    У меня только вызвала вопрос терминология -- абсолютное время это просто aware или всё-таки timezone aware?

    ОтветитьУдалить
  2. Спасибо, Александр.

    Задумался.

    Насколько помню, в документации явно говорилось что aware - это datetime и time без tzinfo или если tzinfo.utcoffset(...) возвращает None.

    Как мне кажется, определение однозначное.

    timezone, которые не знают utcoffset не имеют смысла вообще.

    Считаешь, что это нужно отдельно уточнить в тексте?

    Или имел в виду нечто совсем иное?

    ОтветитьУдалить
  3. Я бы хотел дополнить, что с зонами из pytz нельзя делать так - naive_time.replace(tzinfo=localtz).
    Надо делать localtz.localize(naive_time).

    ОтветитьУдалить
  4. > Насколько помню, в документации явно говорилось что aware - это datetime и time без tzinfo или если tzinfo.utcoffset(...) возвращает None.

    Это как раз naive вроде:

    An object d of type time or datetime may be naive or aware. d is aware if d.tzinfo is not None and d.tzinfo.utcoffset(d) does not return None. If d.tzinfo is None, or if d.tzinfo is not None but d.tzinfo.utcoffset(d) returns None

    > Или имел в виду нечто совсем иное?

    Не. Я просто не до конца сразу вспомнил терминологию.

    ОтветитьУдалить
  5. Тьфу. Конечно, naive. В общем, мы друг друга поняли.

    ОтветитьУдалить
  6. Deepwalker, категорически не согласен.
    Во первых, .localize - специфический для pytz метод. В других библиотеках, реализующих временные зоны, его нет.

    datetime.astimezone есть всегда, и всегда работает правильно (для pytz tzino.utcoffset вызовет тот самый .localize).

    Во вторых, в "правильной" программе нет нужды преобразовывать локальное относительное время через pytz. Потому что его просто неоткуда взять - база данных и прочее окружение отдает utc, для которого replace вполне оправдан.

    Как мне кажется, стоит избегать получения naive local time и преобразования их в pytz.localize. К тому же уходим от очень запутанного вопроса: is_dst в .localize должен быть True или False? pytz сам его не угадывает.

    ОтветитьУдалить
  7. Мы может не совсем друг друга поняли, но представим себе получение datetime через web форму. Мы получаем его без указания таймзоны, но знаем зону пользователя.

    Получаем зону от pytz, и ей уже обрабатываем время, чтобы из него можно было получить utc.

    Есть другой вариант?

    is_dst конечно проблема, но эта проблема не pytz, а более общая.

    ОтветитьУдалить
  8. Ааа, вот о чем речь!
    Да, в случае приема времени из веб формы использование pytz звучит разумно. Временная зона пользователя, как я понимаю, задается в настройках?

    Наверное, все же лучше еще в браузере формировать дату в виде 2011-02-28T21:19+02:00 если это возможно.

    Моя главная претензия к .localize именно та, что далеко не все объекты tzinfo этот .localize имеют.

    ОтветитьУдалить
  9. "Это наши привычные смещения относительно времени по Гринвичу. Например, в моём Киеве локальное время отличается на +2 часа (летом +3)"
    Скорее, тут стоит говорить о всемирном координированном времени (UTC), ведь если сравнить время в GB и UA - будет постоянно +2, поэтому в некоторых источниках Киев указывается как GMT+2. В то же время, от UTC как раз зимой два часа, а летом три.

    ОтветитьУдалить
    Ответы
    1. Да, вместо «времени по Гринвичу» более корректно употреблять UTC.

      Удалить
  10. Хм, у Армина Ронашера отчасти другие выводы с точки зрения Best Practice.. идея принимать «относительное» время исключительно как UTC довольно красивая. Но статьи друг друга дополняют, спасибо огромное!

    ОтветитьУдалить
    Ответы
    1. На самом деле разница небольшая.
      Я предлагаю использовать абсолютное время.
      Армин предпочитает относительное время, рассматривая его исключительно как utc.
      Работают оба метода. Мне комфортней абсолютное время, но это не принципиально.

      Удалить
    2. Для меня «относительное» в utc как-то проще выглядит. И как-то приятнее строго отелить внутренний формат по типу времени, и только с ним работать. Например, .time() и .date() будут иметь разный смысл, если объекты вдруг окажутся с разными временными зонами. Армин ещё пиклы упоминает. Хотя возможно это мелочи на практике.

      Удалить
    3. Тогда внимательно следите, чтобы «относительные utc» не перемешались случайно с «относительными локальными». Python вас не сможет предупредить в этом случае.
      Про pickle.
      Размер чуть больше — но я бы не стал на этом зацикливаться. pickle изначально не был предназначен для экономии места, если оно важно — берите другой сериализатор.
      То, что кривой объект timezone может поломать pickle — просто общее место. Любой кривой объект может поломать. С другой стороны, pytz, например, не ломает — так что возражение по меньшей мере странное.

      Удалить
  11. Спасибо, полезная, структурированная информация

    ОтветитьУдалить