summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMathieu Le Marec - Pasquet <kiorky@cryptelium.net>2020-10-12 01:54:03 +0200
committerMathieu Le Marec - Pasquet <kiorky@cryptelium.net>2020-11-02 17:10:37 +0100
commit4ea6aaa4432c6b49fcdc7192fa815e6b45ff8ddd (patch)
treef39d3b91cea7613c4ab404dabe3c7186ae480961
parent3812f4df00700dc23e27de51ba7bd9195accba86 (diff)
downloadcroniter-dst.tar.gz
Rework DST handlingdst
-rw-r--r--src/croniter/croniter.py158
-rwxr-xr-xsrc/croniter/tests/test_croniter.py125
2 files changed, 225 insertions, 58 deletions
diff --git a/src/croniter/croniter.py b/src/croniter/croniter.py
index 96dec79..21e70ba 100644
--- a/src/croniter/croniter.py
+++ b/src/croniter/croniter.py
@@ -40,6 +40,72 @@ class CroniterNotAlphaError(CroniterBadCronError):
pass
+def zerodate(d):
+ return d.replace(hour=0, minute=0, second=0, microsecond=0)
+
+
+def timedelta_to_seconds(td):
+ """
+ Converts a 'datetime.timedelta' object `td` into seconds contained in
+ the duration.
+ Note: We cannot use `timedelta.total_seconds()` because this is not
+ supported by Python 2.6.
+ """
+ return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) \
+ / 10**6
+
+
+def datetime_to_timestamp(d):
+ """
+ Converts a `datetime` object `d` into a UNIX timestamp.
+ """
+ if d.tzinfo is not None:
+ d = d.replace(tzinfo=None) - d.utcoffset()
+ return timedelta_to_seconds(d - datetime.datetime(1970, 1, 1))
+
+
+def timestamp_to_datetime(timestamp, tzinfo=None):
+ """
+ Converts a UNIX timestamp `timestamp` into a `datetime` object.
+ """
+ result = datetime.datetime.utcfromtimestamp(timestamp)
+ if tzinfo:
+ result = result.replace(tzinfo=tzutc()).astimezone(tzinfo)
+ return result
+
+
+def get_next_dst_window(dt1, dt2=None):
+ '''
+ Return the lower, upper bound of the first DST transition between
+ the two dates, and if it is the summer, or winter one.
+ '''
+ if dt2 is None:
+ dt2 = dt1 + relativedelta(days=3)
+ minh = min(zerodate(dt1), zerodate(dt2))
+ minhdst = timedelta_to_seconds(minh.utcoffset())
+ is_summer = True
+ window = None, None, is_summer
+ for hour in range(
+ int(
+ round(
+ croniter._timedelta_to_seconds(abs(dt1 - dt2)) / 60)
+ )
+ ):
+ curh = minh + relativedelta(hours=hour)
+ upb = timestamp_to_datetime(datetime_to_timestamp(curh), tzinfo=curh.tzinfo)
+ coffset = timedelta_to_seconds(upb.utcoffset())
+ if coffset != minhdst:
+ ilowb = upb - relativedelta(hours=1)
+ lowb = timestamp_to_datetime(datetime_to_timestamp(ilowb), tzinfo=curh.tzinfo)
+ lowboffset = timedelta_to_seconds(lowb.utcoffset())
+ # impossible to happen for now
+ if upb is None:
+ raise CroniterError('DST window error')
+ window = lowb, upb, coffset > lowboffset
+ break
+ return window
+
+
class croniter(object):
MONTHS_IN_YEAR = 12
RANGES = (
@@ -149,20 +215,13 @@ class croniter(object):
"""
Converts a `datetime` object `d` into a UNIX timestamp.
"""
- if d.tzinfo is not None:
- d = d.replace(tzinfo=None) - d.utcoffset()
-
- return cls._timedelta_to_seconds(d - datetime.datetime(1970, 1, 1))
+ return datetime_to_timestamp(d)
def _timestamp_to_datetime(self, timestamp):
"""
Converts a UNIX timestamp `timestamp` into a `datetime` object.
"""
- result = datetime.datetime.utcfromtimestamp(timestamp)
- if self.tzinfo:
- result = result.replace(tzinfo=tzutc()).astimezone(self.tzinfo)
-
- return result
+ return timestamp_to_datetime(timestamp, tzinfo=self.tzinfo)
@classmethod
def _timedelta_to_seconds(cls, td):
@@ -172,8 +231,7 @@ class croniter(object):
Note: We cannot use `timedelta.total_seconds()` because this is not
supported by Python 2.6.
"""
- return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) \
- / 10**6
+ return timedelta_to_seconds(td)
def _get_next(self, ret_type=None, is_prev=None):
if is_prev is None:
@@ -204,36 +262,9 @@ class croniter(object):
else:
result = self._calc(self.cur, expanded,
nth_weekday_of_month, is_prev)
-
- # DST Handling for cron job spanning accross days
- dtstarttime = self._timestamp_to_datetime(self.dst_start_time)
- dtstarttime_utcoffset = (
- dtstarttime.utcoffset() or datetime.timedelta(0))
- dtresult = self._timestamp_to_datetime(result)
- lag = lag_hours = 0
- # do we trigger DST on next crontab (handle backward changes)
- dtresult_utcoffset = dtstarttime_utcoffset
- if dtresult and self.tzinfo:
- dtresult_utcoffset = dtresult.utcoffset()
- lag_hours = (
- self._timedelta_to_seconds(dtresult - dtstarttime) / (60 * 60)
- )
- lag = self._timedelta_to_seconds(
- dtresult_utcoffset - dtstarttime_utcoffset
- )
- hours_before_midnight = 24 - dtstarttime.hour
- if dtresult_utcoffset != dtstarttime_utcoffset:
- if (
- (lag > 0 and abs(lag_hours) >= hours_before_midnight)
- or (lag < 0 and
- ((3600 * abs(lag_hours) + abs(lag)) >= hours_before_midnight * 3600))
- ):
- dtresult = dtresult - datetime.timedelta(seconds=lag)
- result = self._datetime_to_timestamp(dtresult)
- self.dst_start_time = result
self.cur = result
if issubclass(ret_type, datetime.datetime):
- result = dtresult
+ result = timestamp_to_datetime(self.cur, tzinfo=self.tzinfo)
return result
# iterator protocol, to enable direct use of croniter
@@ -455,9 +486,56 @@ class croniter(object):
month, year = dst.month, dst.year
next = True
break
+ dst = dst.replace(microsecond=0)
+ # if a DST occurs during the 24 hours between result candidate
+ # and selected original date:
+ # we check if final candidate is occuring during
+ # the one-hour DST transition.
+ # In this case:
+ # Summer time (+1h delta): 2(-3)h -> 3h
+ # the algo is NEXTing: we add one hour for candidates between the window
+ # 0 * * * * -> xxxx 0100
+ # 0 * * * * -> xxxx 0300
+ # 0 * * * * -> xxxx 0400
+ # 0 * * * * -> xxxx 0500
+ # the algo is PREVing: we remove one hour for candidates between the window
+ # 0 * * * * -> xxxx 0400
+ # 0 * * * * -> xxxx 0300
+ # 0 * * * * -> xxxx 0100
+ # 0 * * * * -> xxxx 0000
+ # Winter time (+1h delta): 2(-3)h -> 1h
+ # the algo is NEXTing: we add 2 hours
+ # 0 * * * * -> xxxx 0300
+ # 0 * * * * -> xxxx 0600
+ # 0 * * * * -> xxxx 0700
+ # the algo is PREVing: we remove two hour
+ # 0 * * * * -> xxxx 0300
+ # 0 * * * * -> xxxx 0100
+ # 0 * * * * -> xxxx 0000
+ if dst.tzinfo is not None:
+ curdt = self._timestamp_to_datetime(self.cur)
+ lowb, upb, is_summer = get_next_dst_window(curdt)
+ ohl = dst + relativedelta(hours=1)
+ if lowb is not None:
+ if (
+ is_summer and
+ ((curdt <= lowb and dst > lowb) or
+ (curdt < lowb and dst >= lowb))
+ ):
+ sign = is_prev and -1 or 1
+ dst += sign * relativedelta(hours=1)
+ next = False
+ elif (
+ not is_summer and
+ (curdt >= upb) and
+ (dst < lowb)
+ ):
+ sign = is_prev and -1 or 1
+ dst += sign * relativedelta(hours=1)
+ next = False
if next:
continue
- return self._datetime_to_timestamp(dst.replace(microsecond=0))
+ return self._datetime_to_timestamp(dst)
if is_prev:
raise CroniterBadDateError("failed to find prev date")
diff --git a/src/croniter/tests/test_croniter.py b/src/croniter/tests/test_croniter.py
index 94d678b..ab232d6 100755
--- a/src/croniter/tests/test_croniter.py
+++ b/src/croniter/tests/test_croniter.py
@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
import unittest
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from functools import partial
from time import sleep
import pytz
@@ -11,6 +11,7 @@ from croniter import croniter, croniter_range, CroniterBadDateError, CroniterBad
from croniter.tests import base
from tzlocal import get_localzone
import dateutil.tz
+from dateutil.tz import tzutc
class CroniterTest(base.TestCase):
@@ -739,47 +740,57 @@ class CroniterTest(base.TestCase):
ct = croniter('*/30 * * * *', tz.localize(start))
self.assertScheduleTimezone(lambda: ct.get_prev(datetime), reversed(expected_schedule))
- def test_std_dst(self):
+ def test_std_dst1(self):
"""
DST tests
This fixes https://github.com/taichino/croniter/issues/82
"""
+ ret = []
+ #
tz = pytz.timezone('Europe/Warsaw')
- # -> 2017-03-26 01:59+1:00 -> 03:00+2:00
local_date = tz.localize(datetime(2017, 3, 26))
val = croniter('0 0 * * *', local_date).get_next(datetime)
- self.assertEqual(val, tz.localize(datetime(2017, 3, 27)))
+ ret.append(val.isoformat())
#
local_date = tz.localize(datetime(2017, 3, 26, 1))
cr = croniter('0 * * * *', local_date)
val = cr.get_next(datetime)
- self.assertEqual(val, tz.localize(datetime(2017, 3, 26, 3)))
+ ret.append(val.isoformat())
val = cr.get_current(datetime)
- self.assertEqual(val, tz.localize(datetime(2017, 3, 26, 3)))
-
+ ret.append(val.isoformat())
+ self.assertEqual(ret,
+ ['2017-03-27T01:00:00+02:00',
+ '2017-03-26T03:00:00+02:00',
+ '2017-03-26T03:00:00+02:00'])
# -> 2017-10-29 02:59+2:00 -> 02:00+1:00
+ ret = []
local_date = tz.localize(datetime(2017, 10, 29))
val = croniter('0 0 * * *', local_date).get_next(datetime)
- self.assertEqual(val, tz.localize(datetime(2017, 10, 30)))
+ ret.append(val.isoformat())
local_date = tz.localize(datetime(2017, 10, 29, 1, 59))
val = croniter('0 * * * *', local_date).get_next(datetime)
- self.assertEqual(
- val.replace(tzinfo=None),
- tz.localize(datetime(2017, 10, 29, 2)).replace(tzinfo=None))
+ ret.append(val.isoformat())
local_date = tz.localize(datetime(2017, 10, 29, 2))
val = croniter('0 * * * *', local_date).get_next(datetime)
- self.assertEqual(val, tz.localize(datetime(2017, 10, 29, 3)))
+ ret.append(val.isoformat())
local_date = tz.localize(datetime(2017, 10, 29, 3))
val = croniter('0 * * * *', local_date).get_next(datetime)
- self.assertEqual(val, tz.localize(datetime(2017, 10, 29, 4)))
+ ret.append(val.isoformat())
local_date = tz.localize(datetime(2017, 10, 29, 4))
val = croniter('0 * * * *', local_date).get_next(datetime)
- self.assertEqual(val, tz.localize(datetime(2017, 10, 29, 5)))
+ ret.append(val.isoformat())
local_date = tz.localize(datetime(2017, 10, 29, 5))
val = croniter('0 * * * *', local_date).get_next(datetime)
- self.assertEqual(val, tz.localize(datetime(2017, 10, 29, 6)))
+ ret.append(val.isoformat())
+ self.assertEqual(ret,
+ ['2017-10-29T23:00:00+01:00',
+ '2017-10-29T02:00:00+02:00',
+ '2017-10-29T03:00:00+01:00',
+ '2017-10-29T04:00:00+01:00',
+ '2017-10-29T05:00:00+01:00',
+ '2017-10-29T06:00:00+01:00'])
def test_std_dst2(self):
"""
@@ -791,6 +802,7 @@ class CroniterTest(base.TestCase):
"""
tz = pytz.timezone("America/Sao_Paulo")
+ # XXX : REvIEWING HERE
local_dates = [
# 17-22: 00 -> 18-00:00
(tz.localize(datetime(2018, 2, 17, 21, 0, 0)),
@@ -821,6 +833,7 @@ class CroniterTest(base.TestCase):
for d in local_dates]
sret1 = ['{0}'.format(d) for d in ret1]
lret1 = ['{0}'.format(d[1]) for d in local_dates]
+ import pdb;pdb.set_trace() ## Breakpoint ##
self.assertEqual(sret1, lret1)
def test_std_dst3(self):
@@ -1159,13 +1172,16 @@ class CroniterRangeTest(base.TestCase):
list(croniter_range(f_start1, dt_stop1, "0 * * * *"))
def test_timezone_dst(self):
- """ Test across DST transition, which technially is a timzone change. """
+ """ Test across DST transition, which technially is a timezone change. """
tz = pytz.timezone("US/Eastern")
+ start = tz.localize(datetime(2020, 11, 1))
+ start = tz.localize(datetime(2020, 10, 31))
start = tz.localize(datetime(2020, 10, 30))
- stop = tz.localize(datetime(2020, 11, 10))
+ stop = tz.localize(datetime(2020, 11, 5))
res = list(croniter_range(start, stop, '0 0 * * *'))
self.assertNotEqual(res[0].tzinfo, res[-1].tzinfo)
- self.assertEqual(len(res), 12)
+ ret = [r.isoformat() for r in res]
+ self.assertEqual(len(res), 7)
def test_extra_hour_day_prio(self):
def datetime_tz(*args, **kw):
@@ -1285,6 +1301,79 @@ class CroniterRangeTest(base.TestCase):
with self.assertRaises(StopIteration):
next(iterable)
+ def test_issue137_dst20200307_summern(self):
+ # summer time
+ localtz = dateutil.tz.gettz("America/Los_Angeles")
+ start = datetime(2020, 3, 7, 23, 0, tzinfo=localtz)
+ iter = croniter("0 */1 * * *", start)
+ ret1 = []
+ for i in range(7):
+ step = iter.get_next(ret_type=datetime)
+ ret1.append(step.astimezone(tzutc()).isoformat())
+ import pdb;pdb.set_trace() ## Breakpoint ##
+ self.assertEqual(
+ ret1,
+ ['2020-03-08T08:00:00+00:00',
+ '2020-03-08T10:00:00+00:00',
+ '2020-03-08T11:00:00+00:00',
+ '2020-03-08T12:00:00+00:00',
+ '2020-03-08T13:00:00+00:00',
+ '2020-03-08T14:00:00+00:00',
+ '2020-03-08T15:00:00+00:00'])
+
+ def test_issue137_dst20200307_summerp(self):
+ localtz = dateutil.tz.gettz("America/Los_Angeles")
+ start = datetime(2020, 3, 8, 6, 0, tzinfo=localtz)
+ iter = croniter("0 */1 * * *", start)
+ ret2 = []
+ for i in range(5):
+ step = iter.get_prev(ret_type=datetime)
+ ret2.append(step.astimezone(tzutc()).isoformat())
+ self.assertEqual(
+ ret2,
+ ['2020-03-08T12:00:00+00:00',
+ '2020-03-08T11:00:00+00:00',
+ '2020-03-08T10:00:00+00:00',
+ '2020-03-08T09:00:00+00:00',
+ '2020-03-08T08:00:00+00:00'])
+
+ def test_issue137_dst20200307_wintern(self):
+ # winter time
+ localtz = dateutil.tz.gettz("America/Los_Angeles")
+ start = datetime(2020, 10, 31, 23, 0, tzinfo=localtz)
+ iter = croniter("0 */1 * * *", start)
+ ret1 = []
+ for i in range(5):
+ step = iter.get_next(ret_type=datetime)
+ ret1.append(step.astimezone(tzutc()).isoformat())
+ self.assertEqual(
+ ret1,
+ ['2020-11-01T07:00:00+00:00',
+ '2020-11-01T08:00:00+00:00',
+ '2020-11-01T10:00:00+00:00',
+ '2020-11-01T11:00:00+00:00',
+ '2020-11-01T12:00:00+00:00'])
+
+ def test_issue137_dst20200307_winterp(self):
+ localtz = dateutil.tz.gettz("America/Los_Angeles")
+ start = datetime(2020, 11, 5, 6, 0, tzinfo=localtz)
+ iter = croniter("0 */1 * * *", start)
+ ret2 = []
+ for i in range(9):
+ step = iter.get_prev(ret_type=datetime)
+ ret2.append(step.astimezone(tzutc()).isoformat())
+ self.assertEqual(
+ ret2,
+ ['2020-11-05T13:00:00+00:00',
+ '2020-11-05T12:00:00+00:00',
+ '2020-11-05T11:00:00+00:00',
+ '2020-11-05T10:00:00+00:00',
+ '2020-11-05T09:00:00+00:00',
+ '2020-11-05T08:00:00+00:00',
+ '2020-11-05T07:00:00+00:00',
+ '2020-11-05T06:00:00+00:00',
+ '2020-11-05T05:00:00+00:00'])
+
if __name__ == '__main__':
unittest.main()