DST-независимое время в Python

У меня есть класс планирования, который принимает объект datetime.time и запускает задачу каждый день в указанное время.

import time
import datetime as dt


class Scheduler:
  def set_time(self, day_time):
    self._day_time = day_time
    self._last_execution = None

  def run(self, f):
    while True:
      time.sleep(10)

      now = dt.datetime.now(dt.UTC)
      today = now.date()

      # Don't run on weekends
      if today.weekday() > 4:
        continue

      # First execution: it can run anytime within the first 5 minutes after the expected time
      if self._last_execution is None:
        begin = dt.datetime.combine(today, self._day_time)
        end = begin + dt.timedelta(minutes=5)
        if begin <= now < end:
          self._last_execution = now
          f()
        else:
          continue

      # Later executions
      scheduled = dt.datetime.combine(today, self._day_time)
      if self._last_execution < scheduled and now >= scheduled:
        self._last_execution = now
        f()


scheduler = Scheduler()
scheduler.set_time(dt.time(8, 30, tzinfo=my_timezone))
scheduler.run(lambda: print("This is printed every day at 8:30"))

Предположим, что эта служба работает годами.

Очевидно, проблема в переменной my_timezone. Я хотел бы, чтобы эта задача выполнялась каждый день в одно и то же время, независимо от того, находимся ли мы в летнее время или нет (что, кстати, означает, что если моя задача должна быть запущена в 2:30 утра, она будет запущена дважды при переходе с летнего времени на летнее и пропускается при переходе с летнего на летнее время).

Внутри scheduler сравнивает время, указанное во время настройки, с dt.datetime.now(dt.UTC).

Интересно, как лучше всего справиться с этим. Есть ли tzinfo я могу указать для учета перехода на летнее время?

Спецификаторы часового пояса, такие как "Europe/Paris", "America/Chicago" и т. д., учитывают переход на летнее время в соответствующем месте. Таким образом, вы можете использовать любой из них в качестве своего tzinfo. Некоторое внимание нужно уделить тому, как запустить задачу дважды в день «возврата»: объект time имеет атрибут fold, который используется для различения двух 2.30 в этот день.

slothrop 19.07.2023 11:46

@slothrop спасибо. Итак, установка my_timezone для географического спецификатора, такого как «Европа/Париж», достаточна для того, что мне нужно? Я не был полностью уверен, потому что создание dt.time(8, 30, tzinfo=my_timezone) выполняется только один раз в начале и боялся, что только тогда будет учитываться часовой пояс. (Я знаю об атрибуте fold)

Spiros 19.07.2023 11:54

Можете ли вы добавить к своему вопросу код, который выполняет сравнение времени?

slothrop 19.07.2023 12:03

@slothrop: готово (я многое упростил по сравнению с моим реальным кодом, который содержит ненужные детали)

Spiros 19.07.2023 12:15

Спасибо. Я думаю, есть пара проблем: (1) в день «возврата» это не вызовет второй запуск (поскольку ваш _day_time будет иметь значение по умолчанию fold=0), (2) today выглядит так, как должно быть. «сегодня» по местному времени, а не по UTC. Например, если мы работаем в часовом поясе Америки/Нью-Йорка и сейчас 2023-07-19 22:00 по местному времени, то today будет 2023-07-20, поэтому begin (или scheduled) будет на 24 часа позже, чем должно. быть.

slothrop 19.07.2023 12:36

Но на самом деле: # Don't run on weekends может убрать некоторую сложность. Есть ли место, где смена часового пояса не происходит в выходные?

slothrop 19.07.2023 12:37

Спасибо за вашу помощь. (1) не проблема (особенно потому, что, как вы заметили, задача не запускается по выходным). О (2) Я думаю, что вижу концептуальную проблему, но не вижу практической; он по-прежнему будет последовательным и будет запускаться каждый день в запрошенное время, верно?

Spiros 19.07.2023 12:42

(2) будет проблемой, если запланированное время таково, что локальное «сегодня» не совпадает с «UTC сегодня» в то время, когда задача предназначена для запуска. В предыдущем примере, если мы находимся в Нью-Йорке и наше запланированное время 22:00, то begin и scheduled всегда будут позже, чем now, и задача никогда не будет выполняться.

slothrop 19.07.2023 12:49

Я вижу сейчас. Вы предлагаете определить now с тем же часовым поясом, который я использую для self._day_time? Кроме того, возвращаясь к исходному вопросу, вы говорите, что я должен использовать pytz для получения «политических» часовых поясов, таких как pytz.timezone('Europe/Paris')?

Spiros 19.07.2023 13:19

(i) Да, верно, (ii) начиная с Python 3.9, лучше использовать zoneinfo: pytz находится в режиме обслуживания. docs.python.org/3/library/zoneinfo.html

slothrop 19.07.2023 13:23

Спасибо. Не могли бы вы опубликовать это как ответ на этот вопрос?

Spiros 19.07.2023 13:32
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
0
11
54
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

zoneinfo (в Python >= 3.9) и pytz (сторонний модуль, который широко использовался до того, как zoneinfo был включен), обеспечивают легкий доступ к часовым поясам IANA, таким как "Europe/Paris", "America/Chicago" и т. д. и т. д. Эти спецификации часовых поясов, которые включают правила перехода на летнее время для подходящего места.

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

Например:

import datetime
import zoneinfo

t0 = datetime.time(11, 15, tzinfo=zoneinfo.ZoneInfo('Europe/Paris'))

# Combine this time with d0 (winter, no DST) and d1 (summer, DST in force)
d0 = datetime.date(2023, 1, 1)
d1 = datetime.date(2023, 7, 1)
print(datetime.datetime.combine(d0, t0))
print(datetime.datetime.combine(d1, t0))

который печатает:

2023-01-01 11:15:00+01:00
2023-07-01 11:15:00+02:00

Обратите внимание на смещения UTC в выходных данных: DST действительно учитывается по мере необходимости.

Судя по комментариям, задача не выполняется по выходным, поэтому здесь мы не рассматриваем сложность того, что должно/должно происходить в день перехода на летнее время. Это было бы проблематично с текущим кодом: как показывает ответ @FObersteiner, добавление положительного timedelta к дате и времени в часовом поясе с переходом на летнее время может парадоксальным образом привести к перемещению назад во времени, так что end будет раньше, чем begin!


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

now = dt.datetime.now(dt.UTC)
today = now.date()

Например, представьте, что self._day_time было задано как time(22, 0, tzinfo=zoneinfo.ZoneInfo('America/NewYork').

Если сейчас 22:00 20.07.2023 в Нью-Йорке (а значит задача должна сработать), то в UTC 02:00 21.07.2023.

Итак, ваш today равен 2023-07-21, и, следовательно, ваши begin и scheduled по местному времени равны 2023-07-21 22:00. Это на 24 часа позже, чем должно быть.

Вместо этого today следует определять с использованием даты и времени с тем же часовым поясом, что и self._day_time.

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

FObersteiner 19.07.2023 14:12

Разве вы не получите один день в году с двумя казнями и один день в этом году без казни? Судя по комментариям (stackoverflow.com/questions/76719932/…), на практике это не проблема, так как задача не запускается по выходным. Я отредактирую, чтобы уточнить.

slothrop 19.07.2023 14:27

Вы уверены, что все переходы на летнее время происходят по выходным для всех часовых поясов, где есть летнее время? ;-)

FObersteiner 19.07.2023 14:28

@FObersteiner существуют правила, определяющие, какое время допустимо при переходе на летнее время. Время с именем часового пояса, а не смещением, уникально. Вы никогда не получите две казни 2023-10-29 03:30 at Europe/Athens

Panagiotis Kanavos 19.07.2023 14:31

@FObersteiner Я исхожу из оговорки OP в комментариях, что эта проблема их не волнует.

slothrop 19.07.2023 14:34

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

Panagiotis Kanavos 19.07.2023 14:35

Двойных казней действительно не будет. (В вопросе и комментариях вы увидите, что ОП изначально указывал, что задача должна выполняться дважды в день «отката», я заметил, что это не так, а затем ОП пояснил, что в конце концов это не проблема).

slothrop 19.07.2023 14:44

Я не совсем уверен, что это спасение. Это может быть нормально в рамках ограничений OP. Но имейте в виду, что вы можете создавать несуществующие даты и времени с помощью арифметики Python timedelta. Правила часового пояса здесь вас не защитят.

FObersteiner 19.07.2023 15:03

@FObersteiner Я думал, что Python достаточно умен, чтобы добавить временную дельту через границу летнего времени, но я проверил это и ошибся. Вам нужно преобразовать в UTC, добавить и преобразовать обратно.

Mark Ransom 19.07.2023 20:26

Ответ @FObersteiner очень четко проясняет ситуацию. Slothrop, не могли бы вы обновить свой ответ, относящийся к этой проблеме? Я все равно приму этот ответ, поскольку он напрямую отвечает на мой вопрос.

Spiros 20.07.2023 11:53

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

slothrop 20.07.2023 12:22

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

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

start = datetime(2023,10,29,1,45,tzinfo=ZoneInfo("Europe/Berlin"))

for i in range(10):
    print(start+timedelta(minutes=i*20))
# 2023-10-29 01:45:00+02:00
# 2023-10-29 02:05:00+02:00
# 2023-10-29 02:25:00+02:00
# 2023-10-29 02:45:00+02:00
# 2023-10-29 03:05:00+01:00 # <<-- "naive timedelta arithmetic" is wrong !
# 2023-10-29 03:25:00+01:00
# 2023-10-29 03:45:00+01:00
# 2023-10-29 04:05:00+01:00
# 2023-10-29 04:25:00+01:00
# 2023-10-29 04:45:00+01:00

Как прокомментировал @MarkRansom, вам нужно будет преобразовать в UTC, прежде чем добавлять продолжительность:

def aware_add(dt, duration):
    return (dt.astimezone(ZoneInfo("UTC")) + duration).astimezone(dt.tzinfo)

for i in range(10):
    print(aware_add(start, timedelta(minutes=i*20)))
# 2023-10-29 01:45:00+02:00
# 2023-10-29 02:05:00+02:00
# 2023-10-29 02:25:00+02:00
# 2023-10-29 02:45:00+02:00 # <<-- after 2:59:59, 2:00:00 follows ...
# 2023-10-29 02:05:00+01:00 # <<-- note the duplicate times ...
# 2023-10-29 02:25:00+01:00 # only the combination of UTC offset and time is unique !
# 2023-10-29 02:45:00+01:00
# 2023-10-29 03:05:00+01:00
# 2023-10-29 03:25:00+01:00
# 2023-10-29 03:45:00+01:00

Другие вопросы по теме