May 13, 2022

Как не разбудить пользователя посреди ночи? Самая полная статья о работе со временем в Python.

Проблематика и мотивации

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

Изучение времени и того, как оно работает, волновало людей уже сотни (если не тысячи) лет, но со “временем” человечество придумало пружинные двигатели (основу всех механических часов), а позже и кварцевые резонаторы (используются в современных электронных и кварцевых часах).

Как компьютер узнаёт время?

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

Большинство современных девайсов использует всё те же кварцевые резонаторы (как внешний генератор тактовых сигналов) или получает сигнал из питающей электросети, но одним только резонатором дело не обходится - тактовая частота требует преобразования, чем занимается так называемая RTC (Real Time Clock) микросхема. Она преобразует тактовую частоту кварцевого резонатора в разные типы времени - календарный, по типу YY-MM-DD и HH-MM-SS, а также в бинарный, в виде счётчика единиц времени - секунд или их долей.

Питает всё это чудо маленькая плоская батарейка, которая устанавливается в материнскую плату девайса. Даже в электронных часах есть такой “запасной” питающий элемент, который может служить определённое количество времени. Эта же батарейка питает систему хранения настроек материнской платы, в том числе BIOS’a, поэтому многим может быть известна схема сброса этих настроек - вынуть батарейку и вставить назад. Тогда и время слетит.

А вот пример этой самой RTC, которую питает плоская батарейка - CR2032.

Python: всё о работе со временем!

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

Модуль time

Несмотря на то, что именно модуль time является базовым средством работы со временем в Python, он часто уступает в удобстве своему младшему брату - модулю datetime, и большинству разработчиков известен скорее из за своей блокирующей функции time.sleep(seconds: int), которая заставляет программу “застыть” в ожидании окончания секундного таймера.

Первое, что нам необходимо - узнать сколько сейчас времени. Это можно сделать при помощи функции time.localtime(), что мы и сделаем:

import time

time.localtime()
# time.struct_time(tm_year=2022, tm_mon=5, tm_mday=10, tm_hour=23, tm_min=0, tm_sec=18, tm_wday=2, tm_yday=131, tm_isdst=0)
# В ответе мы получили обьект типа time.struct_time, это точное время, когда я пишу эту статью!

time.localtime(0)
# time.struct_time(tm_year=1970, tm_mon=1, tm_mday=1, tm_hour=0, tm_min=0, tm_sec=0, tm_wday=3, tm_yday=1, tm_isdst=0)
# Мы получаем обьект с установленным временем в 00:00:00 1 Января 1970-го
# Эта дата - начало отсчёта UNIX-времени!

time.ctime()  # Wed May 11 23:00:00 2022
# Удобочитаемый формат времени

time.ctime(0)  # Thu Jan  1 00:00:00 1970
# Если в вашей системе время настроено на Московский или любой другой отличный от UTC часовой пояс
# то вы получите немного отличный результат, обратите на это внимание!

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

Иногда вместо объекта времени нам необходимо получить так называемый timestamp (тайм-cтемп) - количество секунд прошедших с 00:00:00 (для UTC) 1 Января 1970-го года:

time.time()  # 1652300066.808788
# Это количество секунд прошедших с полуночи 1 Января 1970-го!

А теперь немного поиграемся с часовыми поясами: попробуем вывести текущее время в виде строки и в виде тайм-cтемпа, а потом сменим часовой пояс и повторим то же самое:

# Узнаем текущий часовой пояс
time.tzname    # ('MSK', 'MSK')
time.timezone  # -10800 | Смещение часового пояса в секундах (10800 секунд = 3 часа)

# Выводим текущее время в системе
time.time()   # 1652300626.184014
time.ctime()  # Wed May 10 23:30:00 2022

# Для установки часового пояса понадобится модуль os, импортируйте его к себе
os.environ['TZ'] = 'UTC'
time.tzset()

# Снова выведем время
time.time()   # 1652300626.184296
time.ctime()  # Wed May 10 20:30:00 2022

У неопытного разработчика это может вызвать диссонанс - время разное, но тайм-стемп одинаковый. Это потому, что тайм-стемп всегда считается с учётом часового пояса, поэтому для МСК отсчёт секунд будет начинаться не в полночь (00:00:00 1 Января 1970-го), а в 3 часа ночи!

Также в модуле time есть функция позволяющая замерить количество процессорного времени, затраченного процессом до времени замера - time.process_time(), но я вернусь к этой функции в своей следующей статье о тестировании Python приложений на производительность!

Модуль datetime

Несмотря на всю практичную простоту модуля time, иногда нам нужно что-то более комплексное умное. Об этом также подумали разработчики модуля datetime, создав удобную обёртку вокруг модуля time. Узнать об этом можно открыв сам файлик datetime.py и просмотрев какие модули он импортирует:

import time as _time  # <- Вот он заветный!
import math as _math
import sys
from operator import index as _index

Остальные модули, которые импортирует datetime нам не интересны.

Основной функционал данного модуля предоставляется через четыре класса - date, datetime и timedelta. Названия первых двух классов говорят сами за себя, но третий часто вызывает вопросы у тех, кто впервые с ним сталкивается. Давайте разберём каждый из классов по отдельности и сравним их работу с модулем time!

Если изучить исходники модулей time и datetime и сравнить их, то можно увидеть, что модуль time вообще не предоставляет никакого функционала на Python, он использует написанные на C++ модули для возвращения результата, тогда как модуль datetime написан именно на Python.

Работа с датами

import time as _time
from datetime import date

# Попробуем вывести в читаемом виде сегодняшнюю дату
_time.ctime(_time.time())                      # Thu May 10 19:00:30 2022
date.today().ctime()                           # Thu May 12 00:00:00 2022

# А теперь решим классическую задачу: узнаем является ли сегодняшний день 9-м мая
_time.localtime().tm_mday == 9 and _time.localtime().tm_mon == 5  # False
date.today().day == 9 and date.today().month == 5                 # False
# Увы, но сегодня не 9-е мая...

# Попробуем получить дату из строки
_time.strptime('2022/05/10', '%Y/%m/%d') # time.struct_time(tm_year=2022, tm_mon=5, tm_mday=10)
date.strptime('2022/05/10', '%Y/%m/%d')  # AttributeError...
# Да, через класс date мы не сможем получить дату из строки
# Но мы сможем сделать это используя класс datetime, об этом ниже!

# Зато получить дату через таймстемп мы можем
ts = _time.time()  # Получаем количество секунд (мы могли бы передать их числом)
_time.localtime(ts)         # time.struct_time(tm_year=2022, tm_mon=5...)
date.fromtimestamp(ts)      # 2022-05-10

Несмотря на то, что модуль time в некоторых местах показывает себя лучше, чем функционал класса datetime.date, сам класс всё же бывает полезным в случаях, когда нам приходится работать с датой отдельно от времени.

Лучшее, что есть "из коробки"

Заранее извиняясь за тавтологию, перейду к рассмотрению жемчужины модуля datetime - классу datetime! Он предоставляет куда более гибкий интерфейс для работы с датой и временем, чем модуль time:

import time as _time
from datetime import datetime

# Начнём с того, что снова узнаем сколько сейчас времени
_time.localtime()  # time.struct_time(tm_year=2022, tm_mon=5...)
datetime.now()     # 2022-05-10 19:00:00.746417

# Снова попробуем получить дату и время из строки
_time.strptime('2022/05/10', '%Y/%m/%d')     # time.struct_time(tm_year=2022, tm_mon=5...)
datetime.strptime('2022/05/10', '%Y/%m/%d')  # 2022-05-10 00:00:00
# Теперь мы получаем не ошибку, а обьект класса datetime

# А можно и наоборот, преобразовать обьекты в строку нужного нам вида
_time.strftime('Year: %Y, Month: %m, Day: %d', _time.localtime())  
# Year: 2022, Month: 05, Day: 10
datetime.now().strftime('Year: %Y, Month: %m, Day: %d')  
# Year: 2022, Month: 05, Day: 10

# Сравним как получить таймстемп через datetime
_time.time()                # 1652372410.6999412
datetime.now().timestamp()  # 1652372410.700101

# Попробуем изменить день в обьекте datetime и снова получить таймстемп
datetime.now().timestamp()                  # 1652372637.027454
datetime.now().replace(day=11).timestamp()  # 1652286237.027475
# Мы потеряли приблезительно сутки - 86399 секунд = 23.9 часа

Арифметика дат и времени

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

from datetime import datetime, timedelta

# Простой пример того, как timedelta упрощает вычитание дня из определённой даты
datetime.now().replace(day=datetime.today().day - 1)  # 2022-05-9 20:15:00
datetime.now() - timedelta(days=1)                    # 2022-05-9 20:15:00

# Но нагляднее всего timedelta проявляет себя, когда надо вычесть первый день месяца
# или наоборот прибавить одинь день к 31-му декабря
datetime.today().replace(day=1) - timedelta(days=1)        # 2022-04-30 20:30:00
datetime(year=2021, month=12, day=31) + timedelta(days=1)  # 2022-01-01 00:00:00

# В случае вычитания дат друг из друга мы тоже получим обьект timedelta
time = (datetime.today() + timedelta(days=1)) - datetime.today()
type(time)  # <class 'datetime.timedelta'>

# Сравнивать даты можно также как и любые другие обьекты в Python
datetime.now() == datetime.now()  # False
datetime.now() < datetime.now()   # True
# Это может сбивать с толку, т.к. кажется, что функции выполняются одновременно,
# но по правилам алгебры логики выражения выполняются слева-направо

# Сами обьекты timedelta тоже можно сравнивать
timedelta(days=1) == timedelta(seconds=86400)  # True
timedelta(seconds=1) < timedelta(seconds=1)    # False

Проблема часовых поясов

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

Вместо того, чтобы шутить тут про Мишу из Москвы и Юру из Уфы, я лучше попытаюсь вам обьяснить, насколько всё в действительности сложно. Для начала, нам нужно определиться с понятиями, с тем, что такое часовой пояс (timezone) и с тем, что такое отступ (offset) в контексте часовых поясов.

Отступом называют разницу между часовыми поясами и обозначают как “+N” или “-N” относительно Всемирного координированного времени (UTC). Таким образом Москва живёт с отступом UTC+3, а Уфа с отступом UTC+5. Но существуют страны, которые не придерживаются стандартного 24-х часового круга часовых поясов и используют свои уникальные отступы. Так в одной только Австралии встречаются часовые пояса с отступами: UTC+8:45, UTC+9:30 и UTC+10:30. В столице Либерии (Африка) до 1979-го года часовой пояс шел с отступом в 44 минуты и 30 секунд, а записывался как UTC+00:44:30!

Но отступ и часовой пояс - не одно и то же. Часовой пояс - территория, именуемая в зависимости от своего географического местонахождения. Москва живёт по часовому поясу “Europe/Moscow”, а Нью-Йорк по поясу “America/New_York”. Часовых поясов существует неимоверное множество, они ежегодно обновляются и меняются с учётом различных обстоятельств: скорости вращения Земли, смены правительства, разделения территорий, религиозных взглядов и т.д. Найти полный список можно тут.

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

Сам по себе пакет dateutil представляет очень мощное расширение стандартного модуля datetime, предоставляя набор инструментов для работы с часовыми поясами, парсингом времени, генераторами дат. Очень подробно об этом написано в документации модуля, правда она на английском - https://dateutil.readthedocs.io

Пакет dateutil

Думаю мне не стоит растягивать материал обьяснением виртуальных окружений и установкой сторонних пакетов, поэтому я просто попрошу вас установить нужный нам пакет командой pip install python-dateutil и просто приступить у его рассмотрению:

from datetime import datetime
from dateutil import tz  # <- Этот модуль отвечает за работу с таймзонами

# Начнём с того, что узнаем в каком часовом поясе мы находимся
dt = datetime.now(tz.tzlocal())  # 2022-05-10 23:00:00
dt.tzname()                      # MSK

# А теперь попробуем поменять наш часовой пояс, но не меняя окружение
dt = datetime.now(tz.gettz('Europe/London'))  # 2022-05-10 21:00:00
dt.tzname()                                   # BST (British Summer Time)

# Со временем по UTC всё ещё проще
datetime.now(tz.UTC)  # 2022-05-10 20:00:00

# А теперь решим простую задачу с приведением времени по МСК ко времени по UTC
dt = datetime.now(tz.tzlocal())  # 2022-05-10 23:30:00+03:00 MSK
dt.astimezone(tz.UTC)            # 2022-05-10 20:30:00+00:00 UTC

Чуть выше мы рассматривали класс timedelta из модуля datetime, который был невероятно полезен при выполнении арифметических операций над объектами даты и времени, но он мог показаться вам не слишком удобным. Класс timedelta предоставлял нам возможность интерпретировать временной отрезок только как дни, секунды и микросекунды, что ограничивало удобство его применения. Разработчики dateutil создали расширенный класс представляющий более гибкий временной отрезок - relativedelta. Давайте подробно посмотрим насколько удобно с ним работать:

from datetime import datetime
from dateutil.relativedelta import relativedelta

# Попробуем прибавить к сегодняшней дате год
datetime.now() + relativedelta(days=365)   # 2023-05-10 00:00:00
datetime.now() + relativedelta(months=12)  # 2023-05-10 00:00:00
datetime.now() + relativedelta(years=1)    # 2023-05-10 00:00:00

# А теперь попробуем получить временной отрезок между сегодня и завтра
relativedelta(td, td + relativedelta(days=1))  # relativedelta(days=-1)

# Проробуем заодно узнать, какой сегодня день недели и когда будет понедельник
import calendar  # <- Это встроенный модуль для работы с Григорианским календарём

calendar.day_name[td.weekday()]              # Friday (Пятница)
td + relativedelta(weekday=calendar.MONDAY)  # 2022-05-16

# А что насчёт узнать какой день в году будет 260-м?
datetime(2003, 1, 1) + relativedelta(yearday=260)  # 2003-09-17
datetime(2000, 1, 1) + relativedelta(yearday=260)  # 2000-09-16
# 2000-й был високосным годом, поэтому дата отличается на 1 сутки
# попробуйте то же самое с 2004-м годом, он также был високосным

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

from dateutil import parser

parser.parse('18th November of 2000 at 12:35')  # 2000-11-18 12:35:00
parser.parse('11/18/2000 12:35:00')             # 2000-11-18 12:35:00
parser.parse('2000-11-18 12:35')                # 2000-11-18 12:35:00
# Вся магия происходит под капотом, просто наслаждайтесь

Говоря о dateutil также нельзя не упомянуть модуль rrule предоставляющий функционал “правил повторения”, описанных в iCalendar RFC (В спецификации это называется “Периодами времени”). Его удобно использовать, чтобы создать последовательность дат по определённым правилам, допустим: Все 31-е числа в году или дни с определённым интервалом:

from datetime import datetime
from dateutil.rrule import rrule, YEARLY

# Попробуем узнать 5 високосных лет с 2000-го
start = datetime(2000, 2, 29)  # 29-е Февраля в 2000-м
list(rrule(freq=YEARLY, count=5, dtstart=start))
# [datetime.datetime(2000, 2, 29, 0, 0), 
#  datetime.datetime(2004, 2, 29, 0, 0),
#  datetime.datetime(2008, 2, 29, 0, 0), 
#  datetime.datetime(2012, 2, 29, 0, 0),
#  datetime.datetime(2016, 2, 29, 0, 0)]

# Можно получить даты между двумя другими датами
list(rrule(YEARLY, dtstart=parse("19980101"), until=parse("20220131")))
# [datetime.datetime(1998, 1, 1, 0, 0),
#  ...
#  datetime.datetime(2022, 1, 1, 0, 0)]

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

И про пасху не забудем

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

Общее правило для расчёта даты Пасхи: «Пасха празднуется в первое воскресенье после весеннего полнолуния».
Весеннее полнолуние — первое полнолуние, наступившее не ранее дня весеннего равноденствия.

Попробуем вычислить пасху используя чистый Python:

def _easter(factor):
    """ Метод вычисляющий месяц и день пасхи из фактора"""
    d = 1 + (factor + 27 + (factor + 6) // 40) % 31
    m = 3 + (factor + 26) // 30
    return m, d

def easter(year = 2000):
    """ Метод вычисляющий точную дату пасхи """
    g, e = year % 19, 10  # Лунный цикл в годах и разница календарей
    i = (19*g + 15) % 30  # Количество дней от 21-го марта до полнолуния
    j = (year + year//4 + i) % 7  # День недели полнолуния
    if year > 1600:
				# Получаем разницу между Юлианским и Григорианским календарём
        e = e + year // 100 - 16 - (year // 100 - 16) // 4
    m, d = _easter(i - j + e) # Точные месяц и день пасхи :)
    return datetime(year, int(m), int(d))

easter(2022)  # 2022-04-24 00:00:00

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

Пакет dateutil предоставляет нам универсальную функцию вычисления пасхи с учётом всех особенностей этого праздника:

from dateutil.easter import easter  # <- Функция вычисления пасхи
from dateutil.easter import (
    EASTER_JULIAN,    # <- Алгоритм вычисления по Юлианскому календарю  
    EASTER_ORTHODOX,  # <- Алгоритм вычисления по Григорианскому календарю
    EASTER_WESTERN    # <- Более современный алгоритм
)

easter(EASTER_JULIAN)    # 2022-04-11
easter(EASTER_ORTHODOX)  # 2022-04-24
easter(EASTER_WESTERN)   # 2022-04-17

Чтобы лишний раз убедиться в том, что дата вычисляется правильно, мы может воспользоваться сайтом google.com или встроенной в UNIX-подобные системы календарной утилитой:

$ ncal -o
>> 24 апреля 2022

Заключение

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

Спасибо, что уделили время и прочли этот непростой материал. Я искренне надеюсь, что он улучшит опыт использования вашего программного обеспечения вашими пользователями. Заходите на чашечку чая в мой блог - Закладки разработчика 🔥.