Skip to content

Commit 9583eb7

Browse files
GH-15047: [Python]: switch from pytz to zoneinfo by default for string to tzinfo conversion (#49694)
### Rationale for this change `zoneinfo` is available starting with Python 3.9, so we can now assume that it is available, and so we can switch from returning `pytz` timezones by default to return `zoneinfo` timezones (or `datetime.timezone` for fixed offsets). Only keeping pytz as fallback for strings that are not supported by `zoneinfo` but were supported by `pytz`. Later, we should maybe deprecate that fallback. Generally we should move away from using `pytz`, since the core functionality of having time zones is now available in the standard library (`zoneinfo`), and because the pytz package has several warts / incompatibilities with stdlib datetime (https://blog.ganssle.io/articles/2018/03/pytz-fastest-footgun.html) ### What changes are included in this PR? Whenever we create a python timezone object, which is when converting to pandas or when converting to a `datetime.datetime` object: - always prefer `zoneinfo` for `datetime.datetime` objects - prefer `zoneinfo` for pandas objects _if_ pandas >= 3, to align with the change on the pandas side (pandas-dev/pandas#34916) In either case, when preferring `zoneinfo`, we still fall back to `pytz` for named timezones if `zoneinfo` does not recognize the zone name (apparently pytz can have some common (older) aliases that might not always work with zoneinfo). This fallback is something we could deprecate and remove later on (so we can eventually remove all usage of pytz) ### Are these changes tested? Yes ### Are there any user-facing changes? **This PR includes breaking changes to public APIs.** It is a different object that we return (different class, i.e. `zoneinfo.ZoneInfo` instead of a `pytz.tzinfo.BaseTzInfo`, both are still subclasses of `datetime.tzinfo`), which has some differences in the API, so for people relying on that, this is a breaking change. For the conversion to pandas, pandas itself has made this breaking change anyhow, so for those cases it aligns with that change of pandas. * GitHub Issue: #15047
1 parent 96f8072 commit 9583eb7

8 files changed

Lines changed: 104 additions & 50 deletions

File tree

python/pyarrow/includes/libarrow_python.pxd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ cdef extern from "arrow/python/api.h" namespace "arrow::py::internal" nogil:
213213
CTimePoint TimePoint_from_ns(int64_t val)
214214

215215
CResult[c_string] TzinfoToString(PyObject* pytzinfo)
216-
CResult[PyObject*] StringToTzinfo(c_string)
216+
CResult[PyObject*] StringToTzinfo(c_string, c_bool)
217217

218218

219219
cdef extern from "arrow/python/numpy_init.h" namespace "arrow::py":

python/pyarrow/pandas_compat.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ def _reconstruct_block(item, columns=None, extension_columns=None, return_block=
787787
def make_datetimetz(unit, tz):
788788
if _pandas_api.is_v1():
789789
unit = 'ns' # ARROW-3789: Coerce date/timestamp types to datetime64[ns]
790-
tz = pa.lib.string_to_tzinfo(tz)
790+
tz = pa.lib.string_to_tzinfo(tz, prefer_zoneinfo=_pandas_api.is_ge_v3())
791791
return _pandas_api.datetimetz_type(unit, tz=tz)
792792

793793

@@ -1183,7 +1183,8 @@ def _reconstruct_columns_from_metadata(columns, column_indexes):
11831183
# ARROW-13756: if index is timezone aware DataTimeIndex
11841184
elif pandas_dtype == "datetimetz":
11851185
tz = pa.lib.string_to_tzinfo(
1186-
column_indexes[0]['metadata']['timezone'])
1186+
column_indexes[0]['metadata']['timezone'],
1187+
prefer_zoneinfo=_pandas_api.is_ge_v3())
11871188
level = pd.to_datetime(level, utc=True).tz_convert(tz)
11881189
if _pandas_api.is_ge_v3():
11891190
# with pandas 3+, to_datetime returns a unit depending on the string
@@ -1289,7 +1290,7 @@ def make_tz_aware(series, tz):
12891290
"""
12901291
Make a datetime64 Series timezone-aware for the given tz
12911292
"""
1292-
tz = pa.lib.string_to_tzinfo(tz)
1293+
tz = pa.lib.string_to_tzinfo(tz, prefer_zoneinfo=_pandas_api.is_ge_v3())
12931294
series = (series.dt.tz_localize('utc')
12941295
.dt.tz_convert(tz))
12951296
return series

python/pyarrow/scalar.pxi

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,16 @@ cdef class TimestampScalar(Scalar):
822822
return None
823823

824824
if not dtype.timezone().empty():
825-
tzinfo = string_to_tzinfo(frombytes(dtype.timezone()))
825+
# for datetime.datetime output, always prefer zoneinfo over pytz
826+
prefer_zoneinfo = True
827+
if _pandas_api.have_pandas and dtype.unit() == TimeUnit_NANO:
828+
# but if this method returns a pandas.Timestamp (i.e. pandas installed
829+
# and nano unit) -> adjust preference based on the pandas version
830+
# (i.e. keep returning pytz for older pandas)
831+
prefer_zoneinfo = _pandas_api.is_ge_v3()
832+
tzinfo = string_to_tzinfo(
833+
frombytes(dtype.timezone()), prefer_zoneinfo=prefer_zoneinfo
834+
)
826835
else:
827836
tzinfo = None
828837

python/pyarrow/src/arrow/python/datetime.cc

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -368,13 +368,14 @@ Result<std::string> PyTZInfo_utcoffset_hhmm(PyObject* pytzinfo) {
368368

369369
// Converted from python. See https://github.com/apache/arrow/pull/7604
370370
// for details.
371-
Result<PyObject*> StringToTzinfo(const std::string& tz) {
371+
Result<PyObject*> StringToTzinfo(const std::string& tz, bool prefer_zoneinfo) {
372372
std::string_view sign_str, hour_str, minute_str;
373373
OwnedRef pytz;
374374
OwnedRef zoneinfo;
375375
OwnedRef datetime;
376376

377-
if (internal::ImportModule("pytz", &pytz).ok()) {
377+
// Legacy behavior: prefer pytz objects when available
378+
if (!prefer_zoneinfo && internal::ImportModule("pytz", &pytz).ok()) {
378379
if (MatchFixedOffset(tz, &sign_str, &hour_str, &minute_str)) {
379380
int sign = -1;
380381
if (sign_str == "+") {
@@ -406,7 +407,7 @@ Result<PyObject*> StringToTzinfo(const std::string& tz) {
406407
return tzinfo;
407408
}
408409

409-
// catch fixed offset if pytz is not present
410+
// Handle fixed offsets with datetime.timezone
410411
if (MatchFixedOffset(tz, &sign_str, &hour_str, &minute_str)) {
411412
RETURN_NOT_OK(internal::ImportModule("datetime", &datetime));
412413
int sign = -1;
@@ -447,7 +448,7 @@ Result<PyObject*> StringToTzinfo(const std::string& tz) {
447448
return tzinfo;
448449
}
449450

450-
// fallback on zoneinfo if tz is string and pytz is not present
451+
// Use zoneinfo for named timezones when available
451452
if (internal::ImportModule("zoneinfo", &zoneinfo).ok()) {
452453
OwnedRef class_zoneinfo;
453454
RETURN_NOT_OK(
@@ -456,12 +457,25 @@ Result<PyObject*> StringToTzinfo(const std::string& tz) {
456457
PyUnicode_FromStringAndSize(tz.c_str(), static_cast<Py_ssize_t>(tz.size())));
457458
auto tzinfo =
458459
PyObject_CallFunctionObjArgs(class_zoneinfo.obj(), py_tz_string.obj(), NULL);
460+
if (tzinfo != nullptr) {
461+
return tzinfo;
462+
}
463+
464+
// Keep backwards compatibility for named timezones only available in pytz
465+
PyErr_Clear();
466+
}
467+
468+
if (internal::ImportModule("pytz", &pytz).ok()) {
469+
OwnedRef timezone;
470+
RETURN_NOT_OK(internal::ImportFromModule(pytz.obj(), "timezone", &timezone));
471+
OwnedRef py_tz_string(
472+
PyUnicode_FromStringAndSize(tz.c_str(), static_cast<Py_ssize_t>(tz.size())));
473+
auto tzinfo = PyObject_CallFunctionObjArgs(timezone.obj(), py_tz_string.obj(), NULL);
459474
RETURN_IF_PYERROR();
460475
return tzinfo;
461476
}
462477

463-
return Status::Invalid(
464-
"Pytz package or Python>=3.8 for zoneinfo module must be installed.");
478+
return Status::Invalid("The zoneinfo module or pytz package must be installed.");
465479
}
466480

467481
Result<std::string> TzinfoToString(PyObject* tzinfo) {

python/pyarrow/src/arrow/python/datetime.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ Result<int64_t> PyDateTime_utcoffset_s(PyObject* pydatetime);
188188
/// * An absolute time zone offset of the form +XX:XX or -XX:XX, such as +07:30
189189
/// GIL must be held when calling this method.
190190
ARROW_PYTHON_EXPORT
191-
Result<PyObject*> StringToTzinfo(const std::string& tz);
191+
Result<PyObject*> StringToTzinfo(const std::string& tz, bool prefer_zoneinfo = true);
192192

193193
/// \brief Convert a time zone object to a string representation.
194194
///

python/pyarrow/tests/test_pandas.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import multiprocessing as mp
2222
import sys
2323
import warnings
24+
import zoneinfo
2425

2526
from collections import OrderedDict
2627
from datetime import date, datetime, time, timedelta, timezone
@@ -1168,10 +1169,23 @@ def test_python_datetime(self):
11681169
def test_python_datetime_with_pytz_tzinfo(self):
11691170
pytz = pytest.importorskip("pytz")
11701171

1171-
for tz in [pytz.utc, pytz.timezone('US/Eastern'), pytz.FixedOffset(1)]:
1172-
values = [datetime(2018, 1, 1, 12, 23, 45, tzinfo=tz)]
1172+
timezones_pytz = [pytz.utc, pytz.timezone('US/Eastern'), pytz.FixedOffset(1)]
1173+
timezones_zoneinfo = [
1174+
zoneinfo.ZoneInfo('UTC'),
1175+
zoneinfo.ZoneInfo('US/Eastern'),
1176+
timezone(timedelta(minutes=1))
1177+
]
1178+
1179+
for tz, tz_zoneinfo in zip(timezones_pytz, timezones_zoneinfo):
1180+
values = [tz.localize(datetime(2018, 1, 1, 12, 23, 45))]
11731181
df = pd.DataFrame({'datetime': values})
1174-
_check_pandas_roundtrip(df)
1182+
if Version(pd.__version__) >= Version("3.0.0"):
1183+
df_expected = pd.DataFrame(
1184+
{'datetime': [datetime(2018, 1, 1, 12, 23, 45, tzinfo=tz_zoneinfo)]}
1185+
)
1186+
else:
1187+
df_expected = None
1188+
_check_pandas_roundtrip(df, expected=df_expected)
11751189

11761190
@h.given(st.none() | past.timezones)
11771191
@h.settings(deadline=None)
@@ -1183,22 +1197,26 @@ def test_python_datetime_with_pytz_timezone(self, tz):
11831197
_check_pandas_roundtrip(df, check_dtype=False)
11841198

11851199
def test_python_datetime_with_timezone_tzinfo(self):
1186-
pytz = pytest.importorskip("pytz")
11871200
from datetime import timezone
11881201

11891202
values = [datetime(2018, 1, 1, 12, 23, 45, tzinfo=timezone.utc)]
11901203
# also test with index to ensure both paths roundtrip (ARROW-9962)
11911204
df = pd.DataFrame({'datetime': values}, index=values)
11921205
_check_pandas_roundtrip(df, preserve_index=True)
11931206

1194-
# datetime.timezone is going to be pytz.FixedOffset
11951207
hours = 1
11961208
tz_timezone = timezone(timedelta(hours=hours))
1197-
tz_pytz = pytz.FixedOffset(hours * 60)
11981209
values = [datetime(2018, 1, 1, 12, 23, 45, tzinfo=tz_timezone)]
1199-
values_exp = [datetime(2018, 1, 1, 12, 23, 45, tzinfo=tz_pytz)]
12001210
df = pd.DataFrame({'datetime': values}, index=values)
1201-
df_exp = pd.DataFrame({'datetime': values_exp}, index=values_exp)
1211+
if Version(pd.__version__) < Version("3.0.0"):
1212+
# datetime.timezone is going to be pytz.FixedOffset
1213+
pytz = pytest.importorskip("pytz")
1214+
tz_pytz = pytz.FixedOffset(hours * 60)
1215+
values_exp = [datetime(2018, 1, 1, 12, 23, 45, tzinfo=tz_pytz)]
1216+
df_exp = pd.DataFrame({'datetime': values_exp}, index=values_exp)
1217+
else:
1218+
df_exp = None
1219+
12021220
_check_pandas_roundtrip(df, expected=df_exp, preserve_index=True)
12031221

12041222
def test_python_datetime_subclass(self):

python/pyarrow/tests/test_types.py

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from functools import partial
2121
import datetime
2222
import sys
23+
import zoneinfo
2324

2425
import pytest
2526
import hypothesis as h
@@ -491,35 +492,40 @@ def utcoffset(self, dt):
491492

492493
def test_string_to_tzinfo():
493494
string = ['UTC', 'Europe/Paris', '+03:00', '+01:30', '-02:00']
494-
try:
495-
import pytz
496-
expected = [pytz.utc, pytz.timezone('Europe/Paris'),
497-
pytz.FixedOffset(180), pytz.FixedOffset(90),
498-
pytz.FixedOffset(-120)]
499-
result = [pa.lib.string_to_tzinfo(i) for i in string]
500-
assert result == expected
501-
502-
except ImportError:
503-
try:
504-
import zoneinfo
505-
expected = [zoneinfo.ZoneInfo(key='UTC'),
506-
zoneinfo.ZoneInfo(key='Europe/Paris'),
507-
datetime.timezone(datetime.timedelta(hours=3)),
508-
datetime.timezone(
509-
datetime.timedelta(hours=1, minutes=30)),
510-
datetime.timezone(-datetime.timedelta(hours=2))]
511-
result = [pa.lib.string_to_tzinfo(i) for i in string]
512-
assert result == expected
513-
514-
except ImportError:
515-
pytest.skip('requires pytz or zoneinfo to be installed')
516-
517-
518-
def test_timezone_string_roundtrip_pytz():
495+
result = [pa.lib.string_to_tzinfo(i) for i in string]
496+
expected = [
497+
zoneinfo.ZoneInfo('UTC'),
498+
zoneinfo.ZoneInfo('Europe/Paris'),
499+
datetime.timezone(datetime.timedelta(hours=3)),
500+
datetime.timezone(datetime.timedelta(hours=1, minutes=30)),
501+
datetime.timezone(-datetime.timedelta(hours=2)),
502+
]
503+
assert result == expected
504+
505+
506+
def test_string_to_tzinfo_prefer_zoneinfo_false():
519507
pytz = pytest.importorskip("pytz")
508+
result = pa.lib.string_to_tzinfo("Europe/Brussels", prefer_zoneinfo=False)
509+
assert result == pytz.timezone("Europe/Brussels")
510+
result = pa.lib.string_to_tzinfo("+01:30", prefer_zoneinfo=False)
511+
assert result == pytz.FixedOffset(90)
512+
513+
514+
@pytest.mark.skipif(
515+
sys.platform == 'darwin', reason="macOS supports those lower-case names"
516+
)
517+
def test_string_to_tzinfo_pytz_fallback():
518+
pytz = pytest.importorskip("pytz")
519+
result = pa.lib.string_to_tzinfo("europe/brussels")
520+
expected = pytz.timezone("Europe/Brussels")
521+
assert result == expected
522+
520523

521-
tz = [pytz.FixedOffset(90), pytz.FixedOffset(-90),
522-
pytz.utc, pytz.timezone('America/New_York')]
524+
def test_timezone_string_roundtrip():
525+
tz = [datetime.timezone(datetime.timedelta(hours=1, minutes=30)),
526+
datetime.timezone(datetime.timedelta(hours=-1, minutes=-30)),
527+
zoneinfo.ZoneInfo('UTC'),
528+
zoneinfo.ZoneInfo('America/New_York')]
523529
name = ['+01:30', '-01:30', 'UTC', 'America/New_York']
524530

525531
assert [pa.lib.tzinfo_to_string(i) for i in tz] == name

python/pyarrow/types.pxi

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4166,7 +4166,7 @@ def tzinfo_to_string(tz):
41664166
return frombytes(GetResultValue(TzinfoToString(<PyObject*>tz)))
41674167

41684168

4169-
def string_to_tzinfo(name):
4169+
def string_to_tzinfo(name, *, prefer_zoneinfo=True):
41704170
"""
41714171
Convert a time zone name into a time zone object.
41724172
@@ -4177,15 +4177,21 @@ def string_to_tzinfo(name):
41774177
41784178
Parameters
41794179
----------
4180-
name: str
4180+
name: str
41814181
Time zone name.
4182+
prefer_zoneinfo : bool, default True
4183+
If True, resolve named timezones using ``zoneinfo`` first and only
4184+
fall back to ``pytz`` when needed. If False, prefer ``pytz`` when it
4185+
is available.
41824186
41834187
Returns
41844188
-------
41854189
tz : datetime.tzinfo
41864190
Time zone object
41874191
"""
4188-
cdef PyObject* tz = GetResultValue(StringToTzinfo(name.encode('utf-8')))
4192+
cdef PyObject* tz = GetResultValue(
4193+
StringToTzinfo(name.encode('utf-8'), prefer_zoneinfo)
4194+
)
41894195
return PyObject_to_object(tz)
41904196

41914197

0 commit comments

Comments
 (0)