Skip to content

Commit 4ffdab3

Browse files
authored
ENH: support zoneinfo tzinfos (#46425)
* ENH: support zoneinfo tzinfos * add backports.zoneinfo to ci deps * add backports.zoneinfo to pypy file * py38 compat * fix zoneinfo check * fix check on py38 * mypy fixup * mypy fixup * fix tznaive acse * fix fold * whatnsew * flesh out comment
1 parent 57885c6 commit 4ffdab3

File tree

8 files changed

+79
-16
lines changed

8 files changed

+79
-16
lines changed

doc/source/whatsnew/v1.5.0.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ Timedelta
481481

482482
Time Zones
483483
^^^^^^^^^^
484-
-
484+
- Bug in :class:`Timestamp` constructor raising when passed a ``ZoneInfo`` tzinfo object (:issue:`46425`)
485485
-
486486

487487
Numeric

pandas/_libs/tslibs/conversion.pyx

+2-1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ from pandas._libs.tslibs.timezones cimport (
5656
is_fixed_offset,
5757
is_tzlocal,
5858
is_utc,
59+
is_zoneinfo,
5960
maybe_get_tz,
6061
tz_compare,
6162
utc_pytz as UTC,
@@ -532,7 +533,7 @@ cdef _TSObject _create_tsobject_tz_using_offset(npy_datetimestruct dts,
532533
# see PEP 495 https://www.python.org/dev/peps/pep-0495/#the-fold-attribute
533534
if is_utc(tz):
534535
pass
535-
elif is_tzlocal(tz):
536+
elif is_tzlocal(tz) or is_zoneinfo(tz):
536537
localize_tzinfo_api(obj.value, tz, &obj.fold)
537538
else:
538539
trans, deltas, typ = get_dst_info(tz)

pandas/_libs/tslibs/timezones.pxd

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ cdef tzinfo utc_pytz
99

1010
cpdef bint is_utc(tzinfo tz)
1111
cdef bint is_tzlocal(tzinfo tz)
12+
cdef bint is_zoneinfo(tzinfo tz)
1213

1314
cdef bint treat_tz_as_pytz(tzinfo tz)
1415

pandas/_libs/tslibs/timezones.pyx

+39-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ from datetime import (
33
timezone,
44
)
55

6+
try:
7+
# py39+
8+
import zoneinfo
9+
from zoneinfo import ZoneInfo
10+
except ImportError:
11+
zoneinfo = None
12+
ZoneInfo = None
13+
614
from cpython.datetime cimport (
715
datetime,
816
timedelta,
@@ -42,18 +50,43 @@ cdef tzinfo utc_stdlib = timezone.utc
4250
cdef tzinfo utc_pytz = UTC
4351
cdef tzinfo utc_dateutil_str = dateutil_gettz("UTC") # NB: *not* the same as tzutc()
4452

53+
cdef tzinfo utc_zoneinfo = None
54+
4555

4656
# ----------------------------------------------------------------------
4757

58+
cdef inline bint is_utc_zoneinfo(tzinfo tz):
59+
# Workaround for cases with missing tzdata
60+
# https://github.com/pandas-dev/pandas/pull/46425#discussion_r830633025
61+
if tz is None or zoneinfo is None:
62+
return False
63+
64+
global utc_zoneinfo
65+
if utc_zoneinfo is None:
66+
try:
67+
utc_zoneinfo = ZoneInfo("UTC")
68+
except zoneinfo.ZoneInfoNotFoundError:
69+
return False
70+
71+
return tz is utc_zoneinfo
72+
73+
4874
cpdef inline bint is_utc(tzinfo tz):
4975
return (
5076
tz is utc_pytz
5177
or tz is utc_stdlib
5278
or isinstance(tz, _dateutil_tzutc)
5379
or tz is utc_dateutil_str
80+
or is_utc_zoneinfo(tz)
5481
)
5582

5683

84+
cdef inline bint is_zoneinfo(tzinfo tz):
85+
if ZoneInfo is None:
86+
return False
87+
return isinstance(tz, ZoneInfo)
88+
89+
5790
cdef inline bint is_tzlocal(tzinfo tz):
5891
return isinstance(tz, _dateutil_tzlocal)
5992

@@ -210,6 +243,8 @@ cdef inline bint is_fixed_offset(tzinfo tz):
210243
return 1
211244
else:
212245
return 0
246+
elif is_zoneinfo(tz):
247+
return 0
213248
# This also implicitly accepts datetime.timezone objects which are
214249
# considered fixed
215250
return 1
@@ -264,6 +299,8 @@ cdef object get_dst_info(tzinfo tz):
264299
# e.g. pytz.FixedOffset, matplotlib.dates._UTC,
265300
# psycopg2.tz.FixedOffsetTimezone
266301
num = int(get_utcoffset(tz, None).total_seconds()) * 1_000_000_000
302+
# If we have e.g. ZoneInfo here, the get_utcoffset call will return None,
303+
# so the total_seconds() call will raise AttributeError.
267304
return (np.array([NPY_NAT + 1], dtype=np.int64),
268305
np.array([num], dtype=np.int64),
269306
"unknown")
@@ -291,13 +328,13 @@ cdef object get_dst_info(tzinfo tz):
291328
# deltas
292329
deltas = np.array([v.offset for v in (
293330
tz._ttinfo_before,) + tz._trans_idx], dtype='i8')
294-
deltas *= 1000000000
331+
deltas *= 1_000_000_000
295332
typ = 'dateutil'
296333

297334
elif is_fixed_offset(tz):
298335
trans = np.array([NPY_NAT + 1], dtype=np.int64)
299336
deltas = np.array([tz._ttinfo_std.offset],
300-
dtype='i8') * 1000000000
337+
dtype='i8') * 1_000_000_000
301338
typ = 'fixed'
302339
else:
303340
# 2018-07-12 this is not reached in the tests, and this case

pandas/_libs/tslibs/tzconversion.pyx

+6-5
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ from pandas._libs.tslibs.timezones cimport (
4242
is_fixed_offset,
4343
is_tzlocal,
4444
is_utc,
45+
is_zoneinfo,
4546
utc_pytz,
4647
)
4748

@@ -60,7 +61,7 @@ cdef int64_t tz_localize_to_utc_single(
6061
elif is_utc(tz) or tz is None:
6162
return val
6263

63-
elif is_tzlocal(tz):
64+
elif is_tzlocal(tz) or is_zoneinfo(tz):
6465
return val - _tz_localize_using_tzinfo_api(val, tz, to_utc=True)
6566

6667
elif is_fixed_offset(tz):
@@ -135,7 +136,7 @@ timedelta-like}
135136

136137
result = np.empty(n, dtype=np.int64)
137138

138-
if is_tzlocal(tz):
139+
if is_tzlocal(tz) or is_zoneinfo(tz):
139140
for i in range(n):
140141
v = vals[i]
141142
if v == NPY_NAT:
@@ -484,8 +485,8 @@ cdef int64_t tz_convert_from_utc_single(
484485

485486
if is_utc(tz):
486487
return utc_val
487-
elif is_tzlocal(tz):
488-
return utc_val + _tz_localize_using_tzinfo_api(utc_val, tz, to_utc=False)
488+
elif is_tzlocal(tz) or is_zoneinfo(tz):
489+
return utc_val + _tz_localize_using_tzinfo_api(utc_val, tz, to_utc=False, fold=fold)
489490
else:
490491
trans, deltas, typ = get_dst_info(tz)
491492
tdata = <int64_t*>cnp.PyArray_DATA(trans)
@@ -569,7 +570,7 @@ cdef const int64_t[:] _tz_convert_from_utc(const int64_t[:] stamps, tzinfo tz):
569570

570571
if is_utc(tz) or tz is None:
571572
use_utc = True
572-
elif is_tzlocal(tz):
573+
elif is_tzlocal(tz) or is_zoneinfo(tz):
573574
use_tzlocal = True
574575
else:
575576
trans, deltas, typ = get_dst_info(tz)

pandas/_libs/tslibs/vectorized.pyx

+6-5
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ from .timezones cimport (
4040
get_dst_info,
4141
is_tzlocal,
4242
is_utc,
43+
is_zoneinfo,
4344
)
4445
from .tzconversion cimport (
4546
bisect_right_i8,
@@ -117,7 +118,7 @@ def ints_to_pydatetime(
117118

118119
if is_utc(tz) or tz is None:
119120
use_utc = True
120-
elif is_tzlocal(tz):
121+
elif is_tzlocal(tz) or is_zoneinfo(tz):
121122
use_tzlocal = True
122123
else:
123124
trans, deltas, typ = get_dst_info(tz)
@@ -204,7 +205,7 @@ def get_resolution(const int64_t[:] stamps, tzinfo tz=None) -> Resolution:
204205

205206
if is_utc(tz) or tz is None:
206207
use_utc = True
207-
elif is_tzlocal(tz):
208+
elif is_tzlocal(tz) or is_zoneinfo(tz):
208209
use_tzlocal = True
209210
else:
210211
trans, deltas, typ = get_dst_info(tz)
@@ -272,7 +273,7 @@ cpdef ndarray[int64_t] normalize_i8_timestamps(const int64_t[:] stamps, tzinfo t
272273

273274
if is_utc(tz) or tz is None:
274275
use_utc = True
275-
elif is_tzlocal(tz):
276+
elif is_tzlocal(tz) or is_zoneinfo(tz):
276277
use_tzlocal = True
277278
else:
278279
trans, deltas, typ = get_dst_info(tz)
@@ -334,7 +335,7 @@ def is_date_array_normalized(const int64_t[:] stamps, tzinfo tz=None) -> bool:
334335

335336
if is_utc(tz) or tz is None:
336337
use_utc = True
337-
elif is_tzlocal(tz):
338+
elif is_tzlocal(tz) or is_zoneinfo(tz):
338339
use_tzlocal = True
339340
else:
340341
trans, deltas, typ = get_dst_info(tz)
@@ -385,7 +386,7 @@ def dt64arr_to_periodarr(const int64_t[:] stamps, int freq, tzinfo tz):
385386

386387
if is_utc(tz) or tz is None:
387388
use_utc = True
388-
elif is_tzlocal(tz):
389+
elif is_tzlocal(tz) or is_zoneinfo(tz):
389390
use_tzlocal = True
390391
else:
391392
trans, deltas, typ = get_dst_info(tz)

pandas/conftest.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@
7575
del pa
7676
has_pyarrow = True
7777

78+
zoneinfo = None
79+
if pd.compat.PY39:
80+
# Import "zoneinfo" could not be resolved (reportMissingImports)
81+
import zoneinfo # type: ignore[no-redef]
82+
7883
# Until https://github.com/numpy/numpy/issues/19078 is sorted out, just suppress
7984
suppress_npdev_promotion_warning = pytest.mark.filterwarnings(
8085
"ignore:Promotion of numbers and bools:FutureWarning"
@@ -1166,6 +1171,8 @@ def iris(datapath):
11661171
timezone(timedelta(hours=1)),
11671172
timezone(timedelta(hours=-1), name="foo"),
11681173
]
1174+
if zoneinfo is not None:
1175+
TIMEZONES.extend([zoneinfo.ZoneInfo("US/Pacific"), zoneinfo.ZoneInfo("UTC")])
11691176
TIMEZONE_IDS = [repr(i) for i in TIMEZONES]
11701177

11711178

@@ -1191,7 +1198,12 @@ def tz_aware_fixture(request):
11911198
tz_aware_fixture2 = tz_aware_fixture
11921199

11931200

1194-
@pytest.fixture(params=["utc", "dateutil/UTC", utc, tzutc(), timezone.utc])
1201+
_UTCS = ["utc", "dateutil/UTC", utc, tzutc(), timezone.utc]
1202+
if zoneinfo is not None:
1203+
_UTCS.append(zoneinfo.ZoneInfo("UTC"))
1204+
1205+
1206+
@pytest.fixture(params=_UTCS)
11951207
def utc_fixture(request):
11961208
"""
11971209
Fixture to provide variants of UTC timezone strings and tzinfo objects.

pandas/tests/indexes/datetimes/test_constructors.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
OutOfBoundsDatetime,
1616
conversion,
1717
)
18+
from pandas.compat import PY39
1819

1920
import pandas as pd
2021
from pandas import (
@@ -31,6 +32,9 @@
3132
period_array,
3233
)
3334

35+
if PY39:
36+
import zoneinfo
37+
3438

3539
class TestDatetimeIndex:
3640
@pytest.mark.parametrize(
@@ -1128,7 +1132,12 @@ def test_timestamp_constructor_retain_fold(tz, fold):
11281132
assert result == expected
11291133

11301134

1131-
@pytest.mark.parametrize("tz", ["dateutil/Europe/London"])
1135+
_tzs = ["dateutil/Europe/London"]
1136+
if PY39:
1137+
_tzs = ["dateutil/Europe/London", zoneinfo.ZoneInfo("Europe/London")]
1138+
1139+
1140+
@pytest.mark.parametrize("tz", _tzs)
11321141
@pytest.mark.parametrize(
11331142
"ts_input,fold_out",
11341143
[
@@ -1148,6 +1157,7 @@ def test_timestamp_constructor_infer_fold_from_value(tz, ts_input, fold_out):
11481157
result = ts.fold
11491158
expected = fold_out
11501159
assert result == expected
1160+
# TODO: belongs in Timestamp tests?
11511161

11521162

11531163
@pytest.mark.parametrize("tz", ["dateutil/Europe/London"])

0 commit comments

Comments
 (0)