Skip to content

Commit b2fc40c

Browse files
committed
Adds option to control transition normalization behavior.
1 parent 1dc9037 commit b2fc40c

File tree

7 files changed

+206
-18
lines changed

7 files changed

+206
-18
lines changed

docs/index.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,31 @@ given timezone to properly handle any transition that might have occurred.
10081008
'2013-10-27T02:30:00+01:00'
10091009
10101010
1011+
.. versionadded:: 0.6
1012+
1013+
You can now control the normalization behavior:
1014+
1015+
.. code-block:: python
1016+
1017+
import pendulum
1018+
1019+
pendulum.set_transition_rule(pendulum.PRE_TRANSITION)
1020+
1021+
pendulum.create(2013, 3, 31, 2, 30, 0, 0, 'Europe/Paris')
1022+
'2013-03-31T02:30:00+01:00'
1023+
pendulum.create(2013, 10, 27, 2, 30, 0, 0, 'Europe/Paris')
1024+
'2013-10-27T02:30:00+02:00'
1025+
1026+
pendulum.set_transition_rule(pendulum.TRANSITION_ERROR)
1027+
1028+
pendulum.create(2013, 3, 31, 2, 30, 0, 0, 'Europe/Paris')
1029+
# NonExistingTime: The datetime 2013-03-31 02:30:00 does not exist
1030+
pendulum.create(2013, 10, 27, 2, 30, 0, 0, 'Europe/Paris')
1031+
# AmbiguousTime: The datetime 2013-10-27 02:30:00 is ambiguous.
1032+
1033+
Note that it only affects instances at creation time. Shifting time around
1034+
transition times still behaves the same.
1035+
10111036
Shifting time to transition
10121037
---------------------------
10131038

pendulum/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
SECONDS_PER_HOUR, SECONDS_PER_DAY
1515
)
1616

17+
from .tz.timezone import Timezone
18+
19+
PRE_TRANSITION = Timezone.PRE_TRANSITION
20+
POST_TRANSITION = Timezone.POST_TRANSITION
21+
TRANSITION_ERROR = Timezone.TRANSITION_ERROR
22+
1723
# Helpers
1824
instance = Pendulum.instance
1925
parse = Pendulum.parse
@@ -38,6 +44,8 @@
3844
set_transaltor = Pendulum.set_translator
3945
set_to_string_format = Pendulum.set_to_string_format
4046
reset_to_string_format = Pendulum.reset_to_string_format
47+
set_transition_rule = Pendulum.set_transition_rule
48+
get_transition_rule = Pendulum.get_transition_rule
4149

4250
# Standard helpers
4351
min = Pendulum.min

pendulum/pendulum.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ class Pendulum(datetime.datetime, TranslatableMixin):
8383

8484
_MODIFIERS_VALID_UNITS = ['day', 'week', 'month', 'year', 'decade', 'century']
8585

86+
_TRANSITION_RULE = Timezone.POST_TRANSITION
87+
8688
@classmethod
8789
def _safe_create_datetime_zone(cls, obj):
8890
"""
@@ -170,7 +172,7 @@ def __init__(self, year, month, day,
170172
self._datetime = self._tz.convert(datetime.datetime(
171173
year, month, day,
172174
hour, minute, second, microsecond
173-
))
175+
), dst_rule=self._TRANSITION_RULE)
174176

175177
@classmethod
176178
def instance(cls, dt, tz=UTC):
@@ -369,7 +371,7 @@ def create(cls, year=None, month=None, day=None,
369371
dt = datetime.datetime(*cls._create_datetime(
370372
tz, year, month, day, hour, minute, second, microsecond
371373
)[:-1])
372-
dt = tz.convert(dt)
374+
dt = tz.convert(dt, dst_rule=cls._TRANSITION_RULE)
373375

374376
return cls.instance(dt)
375377

@@ -799,6 +801,20 @@ def set_weekend_days(cls, value):
799801
"""
800802
cls._weekend_days = value
801803

804+
# Normalization Rule
805+
@classmethod
806+
def set_transition_rule(cls, rule):
807+
if rule not in [Timezone.PRE_TRANSITION,
808+
Timezone.POST_TRANSITION,
809+
Timezone.TRANSITION_ERROR]:
810+
raise ValueError('Invalid transition rule: {}'.format(rule))
811+
812+
cls._TRANSITION_RULE = rule
813+
814+
@classmethod
815+
def get_transition_rule(cls):
816+
return cls._TRANSITION_RULE
817+
802818
# Testing aids
803819

804820
@classmethod

pendulum/tz/exceptions.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# -*- coding: utf-8 -*-
2+
3+
4+
class TimezoneError(ValueError):
5+
6+
pass
7+
8+
9+
class NonExistingTime(TimezoneError):
10+
11+
message = 'The datetime {} does not exist.'
12+
13+
def __init__(self, dt):
14+
message = self.message.format(dt)
15+
16+
super(NonExistingTime, self).__init__(message)
17+
18+
19+
class AmbiguousTime(TimezoneError):
20+
21+
message = 'The datetime {} is ambiguous.'
22+
23+
def __init__(self, dt):
24+
message = self.message.format(dt)
25+
26+
super(AmbiguousTime, self).__init__(message)

pendulum/tz/timezone.py

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@
77
from .timezone_info import TimezoneInfo, UTC
88
from .breakdown import local_time as _local_time
99
from .transition_type import TransitionType
10+
from .exceptions import NonExistingTime, AmbiguousTime
1011

1112

1213
class Timezone(object):
1314

1415
_cache = {}
1516

17+
PRE_TRANSITION = 'pre'
18+
POST_TRANSITION = 'post'
19+
TRANSITION_ERROR = 'error'
20+
1621
def __init__(self, name, transitions,
1722
transition_types, default_transition_type):
1823
self._name = name
@@ -58,7 +63,7 @@ def load(cls, name):
5863

5964
return cls._cache[name]
6065

61-
def convert(self, dt):
66+
def convert(self, dt, dst_rule=POST_TRANSITION):
6267
"""
6368
Converts or normalizes a datetime.
6469
@@ -69,7 +74,7 @@ def convert(self, dt):
6974
"""
7075
if dt.tzinfo is None:
7176
# we assume local time
72-
converted = self._normalize(dt)
77+
converted = self._normalize(dt, dst_rule=dst_rule)
7378

7479
else:
7580
converted = self._convert(dt)
@@ -79,7 +84,7 @@ def convert(self, dt):
7984

8085
return dt.__class__(*converted)
8186

82-
def _normalize(self, dt):
87+
def _normalize(self, dt, dst_rule=POST_TRANSITION):
8388
# if tzinfo is set, something wrong happened
8489
if dt.tzinfo is not None:
8590
raise ValueError(
@@ -128,36 +133,58 @@ def _normalize(self, dt):
128133
else:
129134
# tr.pre_time < dt < tr.time
130135
# Skipped time
131-
unix_time = tr.unix_time - (tr.pre_time - dt).total_seconds()
136+
if dst_rule == self.TRANSITION_ERROR:
137+
raise NonExistingTime(dt)
138+
elif dst_rule == self.PRE_TRANSITION:
139+
# We do not apply the transition
140+
(unix_time,
141+
transition_type) = self._get_previous_transition_time(tr, dt)
142+
else:
143+
unix_time = tr.unix_time - (tr.pre_time - dt).total_seconds()
132144
elif tr is end:
133145
if tr.pre_time < dt:
134146
# After the last transition.
135147
unix_time = tr.unix_time + (dt - tr.time).total_seconds()
136148
else:
137149
# tr.time <= dt <= tr.pre_time
138150
# Repeated time
139-
unix_time = tr.unix_time + (dt - tr.time).total_seconds()
151+
if dst_rule == self.TRANSITION_ERROR:
152+
raise AmbiguousTime(dt)
153+
elif dst_rule == self.PRE_TRANSITION:
154+
# We do not apply the transition
155+
(unix_time,
156+
transition_type) = self._get_previous_transition_time(tr, dt)
157+
else:
158+
unix_time = tr.unix_time + (dt - tr.time).total_seconds()
140159
else:
141160
if tr.pre_time <= dt < tr.time:
142161
# tr.pre_time <= dt < tr.time
143162
# Skipped time
144-
unix_time = tr.unix_time - (tr.pre_time - dt).total_seconds()
163+
if dst_rule == self.TRANSITION_ERROR:
164+
raise NonExistingTime(dt)
165+
elif dst_rule == self.PRE_TRANSITION:
166+
# We do not apply the transition
167+
(unix_time,
168+
transition_type) = self._get_previous_transition_time(tr, dt)
169+
else:
170+
unix_time = tr.unix_time - (tr.pre_time - dt).total_seconds()
145171
elif tr.time <= dt <= tr.pre_time:
146172
# tr.time <= dt <= tr.pre_time
147173
# Repeated time
148-
unix_time = tr.unix_time + (dt - tr.time).total_seconds()
174+
if dst_rule == self.TRANSITION_ERROR:
175+
raise AmbiguousTime(dt)
176+
elif dst_rule == self.PRE_TRANSITION:
177+
# We do not apply the transition
178+
(unix_time,
179+
transition_type) = self._get_previous_transition_time(tr, dt)
180+
else:
181+
unix_time = tr.unix_time + (dt - tr.time).total_seconds()
149182
else:
150183
# In between transitions
151184
# The actual transition type is the previous transition one
152185

153-
# Fix for negative microseconds for negative timestamps
154-
diff = (dt - tr.pre_time).total_seconds()
155-
if -1 < diff < 0 and tr.unix_time < 0:
156-
diff -= 1
157-
158-
unix_time = tr.unix_time + diff
159-
160-
transition_type = tr.pre_transition_type
186+
(unix_time,
187+
transition_type) = self._get_previous_transition_time(tr, dt)
161188

162189
return self._to_local_time(unix_time, transition_type)
163190

@@ -232,6 +259,17 @@ def _find_transition_index(self, dt, prop='_time'):
232259

233260
return lo
234261

262+
def _get_previous_transition_time(self, tr, dt):
263+
diff = (dt - tr.pre_time).total_seconds()
264+
if -1 < diff < 0 and tr.unix_time < 0:
265+
diff -= 1
266+
267+
unix_time = tr.unix_time + diff
268+
269+
transition_type = tr.pre_transition_type
270+
271+
return unix_time, transition_type
272+
235273
def __repr__(self):
236274
return '<Timezone [{}]>'.format(self._name)
237275

tests/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from contextlib import contextmanager
55

66
from pendulum import Pendulum, Interval
7-
from pendulum.tz import LocalTimezone, timezone
7+
from pendulum.tz import LocalTimezone, timezone, Timezone
88

99

1010
class AbstractTestCase(TestCase):
@@ -17,6 +17,7 @@ def setUp(self):
1717
def tearDown(self):
1818
LocalTimezone.set_local_timezone()
1919
Pendulum.reset_to_string_format()
20+
Pendulum.set_transition_rule(Timezone.POST_TRANSITION)
2021

2122
def assertPendulum(self, d, year, month, day,
2223
hour=None, minute=None, second=None, microsecond=None):

tests/tz_tests/test_timezone.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pendulum
44
from datetime import datetime
55
from pendulum import timezone
6+
from pendulum.tz.exceptions import NonExistingTime, AmbiguousTime
67

78
from .. import AbstractTestCase
89

@@ -41,6 +42,27 @@ def test_skipped_time(self):
4142
self.assertEqual(7200, dt.tzinfo.offset)
4243
self.assertTrue(dt.tzinfo.is_dst)
4344

45+
def test_skipped_time_with_pre_rule(self):
46+
dt = datetime(2013, 3, 31, 2, 30, 45, 123456)
47+
tz = timezone('Europe/Paris')
48+
dt = tz.convert(dt, dst_rule=tz.PRE_TRANSITION)
49+
50+
self.assertEqual(2013, dt.year)
51+
self.assertEqual(3, dt.month)
52+
self.assertEqual(31, dt.day)
53+
self.assertEqual(2, dt.hour)
54+
self.assertEqual(30, dt.minute)
55+
self.assertEqual(45, dt.second)
56+
self.assertEqual(123456, dt.microsecond)
57+
self.assertEqual('Europe/Paris', dt.tzinfo.tz.name)
58+
self.assertEqual(3600, dt.tzinfo.offset)
59+
self.assertFalse(dt.tzinfo.is_dst)
60+
61+
def test_skipped_time_with_error(self):
62+
dt = datetime(2013, 3, 31, 2, 30, 45, 123456)
63+
tz = timezone('Europe/Paris')
64+
self.assertRaises(NonExistingTime, tz.convert, dt, tz.TRANSITION_ERROR)
65+
4466
def test_repeated_time(self):
4567
dt = datetime(2013, 10, 27, 2, 30, 45, 123456)
4668
tz = timezone('Europe/Paris')
@@ -57,6 +79,27 @@ def test_repeated_time(self):
5779
self.assertEqual(3600, dt.tzinfo.offset)
5880
self.assertFalse(dt.tzinfo.is_dst)
5981

82+
def test_repeated_time_pre_rule(self):
83+
dt = datetime(2013, 10, 27, 2, 30, 45, 123456)
84+
tz = timezone('Europe/Paris')
85+
dt = tz.convert(dt, dst_rule=tz.PRE_TRANSITION)
86+
87+
self.assertEqual(2013, dt.year)
88+
self.assertEqual(10, dt.month)
89+
self.assertEqual(27, dt.day)
90+
self.assertEqual(2, dt.hour)
91+
self.assertEqual(30, dt.minute)
92+
self.assertEqual(45, dt.second)
93+
self.assertEqual(123456, dt.microsecond)
94+
self.assertEqual('Europe/Paris', dt.tzinfo.tz.name)
95+
self.assertEqual(7200, dt.tzinfo.offset)
96+
self.assertTrue(dt.tzinfo.is_dst)
97+
98+
def test_repeated_time_with_error(self):
99+
dt = datetime(2013, 10, 27, 2, 30, 45, 123456)
100+
tz = timezone('Europe/Paris')
101+
self.assertRaises(AmbiguousTime, tz.convert, dt, tz.TRANSITION_ERROR)
102+
60103
def test_pendulum_create_basic(self):
61104
dt = pendulum.create(2016, 6, 1, 12, 34, 56, 123456, 'Europe/Paris')
62105

@@ -235,3 +278,34 @@ def test_convert_accept_pendulum_instance(self):
235278

236279
self.assertIsInstanceOfPendulum(new)
237280
self.assertPendulum(new, 2016, 8, 7, 14, 53, 54)
281+
282+
def test_add_time_to_new_transition_does_not_use_transition_rule(self):
283+
pendulum.set_transition_rule(pendulum.TRANSITION_ERROR)
284+
dt = pendulum.create(2013, 3, 31, 1, 59, 59, 999999, 'Europe/Paris')
285+
286+
self.assertPendulum(dt, 2013, 3, 31, 1, 59, 59, 999999)
287+
self.assertEqual('Europe/Paris', dt.timezone_name)
288+
self.assertEqual(3600, dt.offset)
289+
self.assertFalse(dt.is_dst)
290+
291+
dt = dt.add(microseconds=1)
292+
293+
self.assertPendulum(dt, 2013, 3, 31, 3, 0, 0, 0)
294+
self.assertEqual('Europe/Paris', dt.timezone_name)
295+
self.assertEqual(7200, dt.offset)
296+
self.assertTrue(dt.is_dst)
297+
298+
def test_create_uses_transition_rule(self):
299+
pendulum.set_transition_rule(pendulum.PRE_TRANSITION)
300+
dt = pendulum.create(2013, 3, 31, 2, 30, 45, 123456, 'Europe/Paris')
301+
302+
self.assertEqual(2013, dt.year)
303+
self.assertEqual(3, dt.month)
304+
self.assertEqual(31, dt.day)
305+
self.assertEqual(2, dt.hour)
306+
self.assertEqual(30, dt.minute)
307+
self.assertEqual(45, dt.second)
308+
self.assertEqual(123456, dt.microsecond)
309+
self.assertEqual('Europe/Paris', dt.timezone_name)
310+
self.assertEqual(3600, dt.offset)
311+
self.assertFalse(dt.is_dst)

0 commit comments

Comments
 (0)