python

«Eppur si muove!»* или Работаем с таймзонами в Python

  • понедельник, 10 ноября 2014 г. в 02:10:58
http://habrahabr.ru/company/mailru/blog/242615/

На нашей планете Земля, в одно и то же время, в разных географических точках планеты может быть разное время суток. Это следствие того, что наш мир — вращающийся геоид, а не плоский диск, а что наша Солнечная система имеет только одну звезду — Солнце. Ещё со школы всем известно о часовых поясах, и все мы встречались с их проявлениями в реальной жизни («Московское время – 15 часов, в Петропавловске-Камчатском – полночь», джетлаг при дальних перелётах, и т.д.). К несчастью, часовые пояса всего лишь частично основаны на физических особенностях нашего мира, и при компьютерных вычислениях приходится учитывать другие, порой неожиданные, нюансы.

* «И всё-таки она вертится!» — крылатая фраза, которую якобы произнёс Галилео Галилей, покидая процесс инквизиции после отречения от своего убеждения в том, что Земля вращается вокруг Солнца. В нашем случае, увы, это вращение приводит ко всем этим «замечательным» проблемам с часовыми поясами.

Что общего у этой статьи и Галилео? Да, в общем-то, ничего. Боюсь, что если бы наш мир был центром вселенной, нам всё равно пришлось бы иметь дело с таймзонами. Будем считать заголовок моей оплошностью, которую я уже не могу исправить (хотя я могу).

Что такое «Часовой пояс»?

Какой у вас часовой пояс? Если вы ответите «UTC+3» — это будет правильным ответом только на текущий момент времени, но в целом это заявление некорректно. Если вы посмотрите на базу данных часовых поясов, то увидите, к примеру, что Берлин и Вена, несмотря на смещение «UTC+1», имеют разные часовые пояса («Europe/Berlin» и «Europe/Vienna»). Почему так? Причина в том, что они имели разное летнее время (DST) в разные периоды истории. Даже если сегодня эти две страны и эти два города имеют одинаковые правила DST, сто лет назад это было не так. Например, и в Австрии и в Германии в разные периоды времени не было перехода на летнее время: в Австрии с 1920 года, а в Германии с 1918. Во время Второй мировой войны обе страны имели одинаковые правила DST (что не удивительно), однако после её окончания снова рассинхронизировались. Германия отменила переход на летнее время в 1949 и ввела его снова в 1979, Австрия же отменила DST в 1948 и ввела его снова в 1980. Самое же худшее состоит в том, что они даже не согласовали одинаковую дату перехода на летнее время.

И так происходит по всему миру. Для компьютерных вычислений, переход на летнее время — огромная проблема, ведь мы предполагаем, что время имеет непрерывный мотононный ход. С переходом на летнее время у нас каждый год есть час, который повторяется дважды, и есть час, который мы просто пропускаем. Если при записи в лог вы указываете локальное время, у вас можем нарушится порядок строк лога при сортировке.

Цитата из документации pytz:
Так, например, в таймзоне US/Eastern в 2002 году во время окончания действия DST, 27 октября время 01:30 наступило дважды, а во время начала действия DST, 7 апреля время 02:30 не наступило, т.к. в 02:00 часы перевели на час вперёд.

Но в таймзонах хранятся не только правила перехода на летнее время. Некоторые страны меняют часовые пояса, иногда даже без изменения DST. Так, например, в 1915 году Варшава перешла на Центральноевропейское время. В результате в полночь 5 августа 1915 года часы были переведены на 24 минуты назад (при этом в Варшаве действовало летнее время).
Вообще, с часовыми поясами творится ещё больший ад. Есть как минимум одна страна, таймзона которой была различна в течение дня из-за синхронизации времени 0:00 с временем восхода Солнца.

Где же здравый смысл?

Здравый смысл есть и он называется Всемирное координированное время (UTC). UTC — это таймзона без перехода на летнее время и без каких бы то ни было изменений в прошлом. Однако по причине того, что наша Земля — вращающийся геоид и в мире есть вещи, которые мы не можем контролировать, существует проблема корректировочных секунд (leap seconds). Если UTC будет учитывать корректировочные секунды (которые нерегулярны и поэтому их достаточно проблематично учитывать при вычислениях), или не будет (тогда каждая таймзона будет иметь разницу в несколько секунд с UTC), — насколько мне известно, ещё не решено.

Несмотря на это, прямо сейчас UTC — самый безопасный вариант. Из UTC вы можете сконвертировать время в локальное для любого часового пояса. Обратное преобразование, учитывая вышесказанное, невозможно.

Итак, вот главное практическое правило, которое никогда вас не подведёт:
Всегда храните и работайте со временем в UTC. Если вам нужно сохранить оригинальные данные — пишите их отдельно. Никогда не храните локальное время и таймзону!

В чём проблема?

Вообще, на этом статья должна была бы закончиться. Но к несчастью, есть пара вещей, которые необходимо держать в памяти, когда вы программируете на Python. Это наследие архитектурных решений тех давних времён, когда никто не думал о практическом применении языка. Мотивация имела значение, здравый смысл — нет.

В один прекрасный день были приняты следующие решения об архитеутуре модуля datetime стандартной библиотеки Python:
  1. Модуль datetime не должен хранить информацию о таймзонах, потому что таймзоны меняются слишком часто.
  2. С другой стороны, модуль datetime должен давать возможность добавлять в себя информацию о таймзоне (tzinfo).
  3. В модуле datetime должны быть реализованы следующие объекты: date, time, date+time, timedelta.

К несчастью, что-то пошло не так. Основная проблема заключается в том, что объект datetime, в который была добавлена информация о таймзоне (tzinfo), не будет взаимодействовать с объектом datetime без таймзоны:
>>> import pytz, datetime
>>> a = datetime.datetime.utcnow()
>>> b = datetime.datetime.utcnow().replace(tzinfo=pytz.utc)
>>> a < b
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can't compare offset-naive and offset-aware datetimes

Если закрыть глаза на тот ужасный API, с помощью которого вам приходится добавлять информация о таймзоне к объекту datetime, всё равно остаются проблемы. Когда вы работаете с объектами datetime в Питоне, вам рано или поздно придётся добавлять или удалять tzinfo во всех местах вашей программы.

Другая проблема состоит в том, что у вас есть два способа создать объект datetime с текущим временем в Python:
>>> datetime.datetime.utcnow()
datetime.datetime(2011, 7, 15, 8, 30, 55, 375010)
>>> datetime.datetime.now()
datetime.datetime(2011, 7, 15, 10, 30, 57, 70767)

Один возвращает время в UTC, другой — локальное время. Однако объект datetime не скажет вам, что такое «локальное время» (потому что он не имеет информации о таймзоне, по крайней мере до версии Python 3.3), и нет никакого способа узнать, который из этих объектов хранит время в UTC.

Если вы конвертируете UNIX timestamp в объект datetime, вам так же следует быть осторожным при использовании метода datetime.datetime.utcfromtimestamp, потому что он принимает timestamp в локальном времени.

Библиотека datetime так же предоставляет объекты date и time, в которые абсолютно бесполезно добавлять tzinfo. Объект time не может быть переведён в другую таймзону, поскольку для этого нужно знать дату. Объект date вообще имеет смысл только для локальной таймзоны, потому что «сегодня» для меня может быть «вчера» или «завтра» для вас — скажем спасибо чудесному миру часовых поясов.

Так каковы рекомендации специалистов?

Теперь мы знаем, кто виноват. Но что делать? Если мы проигнорируем теоретические проблемы, проявляющиеся только в случае работы с историческими датами в прошлом, то вот вам ряд рекомендаций. На тот случай, если вам приходится работать с историческими датами, есть альтернативный модуль mxDateTime, достаточно качественно спроектированный и даже поддерживающий различные календари (Григорианский и Юлианский).

Используйте UTC внутри программы


Если вам нужно получить текущее время, всегда используйте datetime.datetime.utcnow(). Если вы получаете локальное время от пользователя, всегда тут же преобразовывайте его в UTC. Если однозначного преобразования сделать не получается — сообщайте об этом пользователю, не пытайтесь угадать его время вслепую. Во время перехода на летнее время и обратно, мой iPhone несколько раз не смог правильно перевести время. Я же знаю, когда это нужно сделать, поскольку мне приходится переводить стрелочные часы.

Никогда не используете время с часовым поясом


Это может показаться вам хорошей идеей — всегда добавлять информацию о часовом поясе к объектам datetime, но на самом деле гораздо лучшая идея — не делать этого. Хорошим решением будет использование объекта datetime без tzinfo и с временем по UTC. Учитывайте тот факт, что вы не можете сравнивать время с таймзоной с временем без неё, так же, как не можете смешивать bytes и unicode в Python 3. Используете этот недостаток API в своих целях.
  1. Внутри программы всегда используйте объекты datetime без tzinfo с временем по UTC.
  2. Когда вы взаимодействуете с пользователем, всегда конвертируйте его локальное время UTC и обратно.

Почему вам не нужно добавлять tzinfo в объект datetime? Во-первых, потому, что подавляющая часть библиотек ожидает, что tzinfo будет равно None. Во-вторых, это ужасная идея всегда работать с tzinfo, учитывая кривое API работы с ним. В библиотеке pytz есть альтернативные функции для конвертирования таймзон, потому что реализованное в стандартной библиотеке API для преобразования tzinfo недостаточно гибкое, чтобы работать с большинством реальных таймзон. Если мы не будем использовать объекты tzinfo, есть шанс, что в будущем всё изменится к лучшему.

Другая причина не использовать время с таймзоной заключается в том, что объект tzinfo очень специфичен и сильно зависит от своей реализации. Не существует стандартного способа передавать информацию о таймзоне (за исключением, пожалуй, таймзоны UTC) в другие языки, по HTTP и т.д. К тому же объекты datetime с информацией о таймзоне, зачастую, становятся слишком огромными при сериализации с помощью модуля pickle, или их даже невозможно бывает сериализовать (это зависит от реализации объекта tzinfo).

Преобразования для форматирования


Если вам нужно показать время в таймзоне пользователя, возьмите объект datetime с временем по UTC, добавьте в него таймзону UTC, преобразуйте время в локальное время пользователя и отформатируйте его. Не используйте преобразование таймзоны методами tzinfo, ибо они работают некорректно, используйте pytz. Потом переведите время в «наивное» путём отбрасывания смещения таймзоны из получившегося объекта datetime, который вы создали для форматирования и продолжайте жить счастливо.

Перевёл Dreadatour, текст читал %username%.


Бонус от переводчика для тех, кто дочитал до конца:


Шикарное видео от Tom Scott про таймзоны:
А в следующей своей статье я напишу, где автор неправ, почему он ошибается и как же всё-таки нужно делать правильно.