diff options
author | Mathieu Le Marec - Pasquet <kiorky@cryptelium.net> | 2020-10-12 01:54:03 +0200 |
---|---|---|
committer | Mathieu Le Marec - Pasquet <kiorky@cryptelium.net> | 2020-11-02 17:10:37 +0100 |
commit | 4ea6aaa4432c6b49fcdc7192fa815e6b45ff8ddd (patch) | |
tree | f39d3b91cea7613c4ab404dabe3c7186ae480961 | |
parent | 3812f4df00700dc23e27de51ba7bd9195accba86 (diff) | |
download | croniter-dst.tar.gz |
Rework DST handlingdst
-rw-r--r-- | src/croniter/croniter.py | 158 | ||||
-rwxr-xr-x | src/croniter/tests/test_croniter.py | 125 |
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() |