Skip to content

Commit 5e45255

Browse files
committed
refactor: deprecate pytz in favor of builtin zoneinfo
1 parent 603ede0 commit 5e45255

10 files changed

Lines changed: 149 additions & 692 deletions

File tree

docs/sphinx/source/whatsnew/v0.15.2.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Breaking Changes
1010

1111
Deprecations
1212
~~~~~~~~~~~~
13+
* ``Location.pytz`` is deprecated. Use ``Location.tz`` instead.
14+
(:ghuser:`JoLo90`, :pull:`2757`)
1315

1416

1517
Bug fixes

docs/tutorials/solarposition.ipynb

Lines changed: 74 additions & 632 deletions
Large diffs are not rendered by default.

pvlib/iotools/pvgis.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import requests
2121
import numpy as np
2222
import pandas as pd
23-
import pytz
23+
import zoneinfo
2424
from pvlib.iotools import read_epw
2525

2626
URL = 'https://re.jrc.ec.europa.eu/api/'
@@ -413,10 +413,10 @@ def _coerce_and_roll_tmy(tmy_data, tz, year):
413413
re-interpreted as zero / UTC.
414414
"""
415415
if tz:
416-
tzname = pytz.timezone(f'Etc/GMT{-tz:+d}')
416+
tzname = zoneinfo.ZoneInfo(f'Etc/GMT{-tz:+d}') # noqa: E231
417417
else:
418418
tz = 0
419-
tzname = pytz.timezone('UTC')
419+
tzname = zoneinfo.ZoneInfo('UTC')
420420
new_index = pd.DatetimeIndex([
421421
timestamp.replace(year=year, tzinfo=tzname)
422422
for timestamp in tmy_data.index],

pvlib/location.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from pvlib import solarposition, clearsky, atmosphere, irradiance
1616
from pvlib.tools import _degrees_to_index
17+
from pvlib._deprecation import warn_deprecated
1718

1819

1920
class Location:
@@ -22,13 +23,11 @@ class Location:
2223
time zone, and altitude data associated with a particular geographic
2324
location. You can also assign a name to a location object.
2425
25-
Location objects have two time-zone attributes:
26+
Location objects have a time-zone attribute:
2627
2728
* ``tz`` is an IANA time-zone string.
28-
* ``pytz`` is a pytz-based time-zone object (read only).
2929
30-
The read-only ``pytz`` attribute will stay in sync with any changes made
31-
using ``tz``.
30+
The ``pytz`` attribute is deprecated. Use ``tz`` instead.
3231
3332
Location objects support the print method.
3433
@@ -47,8 +46,8 @@ class Location:
4746
list of valid name strings. An `int` or `float` must be a whole-number
4847
hour offsets from UTC that can be converted to the IANA-supported
4948
'Etc/GMT-N' format. (Note the limited range of the offset N and its
50-
sign-change convention.) Time zones from the pytz and zoneinfo packages
51-
may also be passed here, as they are subclasses of datetime.tzinfo.
49+
sign-change convention.) Time zones from the zoneinfo packages may also
50+
be passed here.
5251
5352
The `tz` attribute is represented as a valid IANA time zone name
5453
string.
@@ -108,17 +107,19 @@ def tz(self, tz_):
108107
if isinstance(tz_, str):
109108
self._zoneinfo = zoneinfo.ZoneInfo(tz_)
110109
elif isinstance(tz_, int):
111-
self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-tz_:+d}")
110+
tz_str = f"Etc/GMT{-tz_:+d}" # noqa: E231
111+
self._zoneinfo = zoneinfo.ZoneInfo(tz_str)
112112
elif isinstance(tz_, float):
113113
if tz_ % 1 != 0:
114114
raise TypeError(
115115
"Floating-point tz has non-zero fractional part: "
116116
f"{tz_}. Only whole-number offsets are supported."
117117
)
118118

119-
self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-int(tz_):+d}")
119+
tz_str = f"Etc/GMT{-int(tz_):+d}" # noqa: E231
120+
self._zoneinfo = zoneinfo.ZoneInfo(tz_str)
120121
elif isinstance(tz_, datetime.tzinfo):
121-
# Includes time zones generated by pytz and zoneinfo packages.
122+
# Includes time zones generated by zoneinfo packages.
122123
self._zoneinfo = zoneinfo.ZoneInfo(str(tz_))
123124
else:
124125
raise TypeError(
@@ -128,8 +129,20 @@ def tz(self, tz_):
128129
)
129130

130131
@property
131-
def pytz(self):
132-
"""The location's pytz time zone (read only)."""
132+
def pytz(self): # pragma: no cover
133+
"""The location's pytz time zone (read only).
134+
135+
.. deprecated::
136+
The ``pytz`` attribute is deprecated. Use the ``tz`` property
137+
instead.
138+
"""
139+
warn_deprecated(
140+
since='0.15.2',
141+
removal='0.17.0',
142+
name='pytz',
143+
obj_type='attribute',
144+
alternative='tz',
145+
)
133146
return pytz.timezone(str(self._zoneinfo))
134147

135148
@classmethod

pvlib/solarposition.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,11 +1360,11 @@ def hour_angle(times, longitude, equation_of_time):
13601360
Corresponding timestamps, must be localized to the timezone for the
13611361
``longitude``.
13621362
1363-
A `pytz.exceptions.AmbiguousTimeError` will be raised if any of the
1364-
given times are on a day when the local daylight savings transition
1365-
happens at midnight. If you're working with such a timezone,
1366-
consider converting to a non-DST timezone (e.g. GMT-4) before
1367-
calling this function.
1363+
An error (AmbiguousTimeError in older pandas, ValueError in newer)
1364+
will be raised if any of the given times are on a day when the local
1365+
daylight savings transition happens at midnight. If you're working
1366+
with such a timezone, consider converting to a non-DST timezone
1367+
(e.g. GMT-4) before calling this function.
13681368
longitude : numeric
13691369
Longitude in degrees
13701370
equation_of_time : numeric

pvlib/tools.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
import contextlib
66
import datetime as dt
7+
from datetime import timezone
78
import warnings
89

910
import numpy as np
1011
import pandas as pd
11-
import pytz
12+
import zoneinfo
1213

1314

1415
def cosd(angle):
@@ -135,8 +136,8 @@ def localize_to_utc(time, location):
135136
"""
136137
if isinstance(time, dt.datetime):
137138
if time.tzinfo is None:
138-
time = location.pytz.localize(time)
139-
time_utc = time.astimezone(pytz.utc)
139+
time = time.replace(tzinfo=location._zoneinfo)
140+
time_utc = time.astimezone(timezone.utc)
140141
else:
141142
try:
142143
time_utc = time.tz_convert('UTC')
@@ -162,11 +163,11 @@ def datetime_to_djd(time):
162163
"""
163164

164165
if time.tzinfo is None:
165-
time_utc = pytz.utc.localize(time)
166+
time_utc = time.replace(tzinfo=timezone.utc)
166167
else:
167-
time_utc = time.astimezone(pytz.utc)
168+
time_utc = time.astimezone(timezone.utc)
168169

169-
djd_start = pytz.utc.localize(dt.datetime(1899, 12, 31, 12))
170+
djd_start = dt.datetime(1899, 12, 31, 12, tzinfo=timezone.utc)
170171
djd = (time_utc - djd_start).total_seconds() * 1.0/(60 * 60 * 24)
171172

172173
return djd
@@ -189,10 +190,10 @@ def djd_to_datetime(djd, tz='UTC'):
189190
The resultant datetime localized to tz
190191
"""
191192

192-
djd_start = pytz.utc.localize(dt.datetime(1899, 12, 31, 12))
193+
djd_start = dt.datetime(1899, 12, 31, 12, tzinfo=timezone.utc)
193194

194195
utc_time = djd_start + dt.timedelta(days=djd)
195-
return utc_time.astimezone(pytz.timezone(tz))
196+
return utc_time.astimezone(zoneinfo.ZoneInfo(tz))
196197

197198

198199
def _pandas_to_doy(pd_object):

tests/iotools/test_midc.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import pandas as pd
22
import pytest
3-
import pytz
43

54
from pvlib.iotools import midc
65
from tests.conftest import TESTS_DATA_DIR, RERUNS, RERUNS_DELAY

tests/test_clearsky.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import numpy as np
44
from numpy import nan
55
import pandas as pd
6-
import pytz
6+
import zoneinfo
77
from scipy.linalg import hankel
88

99
import pytest
@@ -770,7 +770,7 @@ def test_bird():
770770
times = pd.date_range(start='1/1/2015 0:00', end='12/31/2015 23:00',
771771
freq='h')
772772
tz = -7 # test timezone
773-
gmt_tz = pytz.timezone('Etc/GMT%+d' % -(tz))
773+
gmt_tz = zoneinfo.ZoneInfo(f'Etc/GMT{-tz:+d}') # noqa: E231
774774
times = times.tz_localize(gmt_tz) # set timezone
775775
times_utc = times.tz_convert('UTC')
776776
# match test data from BIRD_08_16_2012.xls

tests/test_location.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99

1010
import pytest
1111

12-
import pytz
13-
1412
import pvlib
1513
from pvlib import location
1614
from pvlib.location import Location, lookup_altitude
@@ -37,29 +35,26 @@ def test_location_all():
3735
pytest.param('Asia/Yangon', 'Asia/Yangon'),
3836
pytest.param(datetime.timezone.utc, 'UTC'),
3937
pytest.param(zoneinfo.ZoneInfo('Etc/GMT-7'), 'Etc/GMT-7'),
40-
pytest.param(pytz.timezone('US/Arizona'), 'US/Arizona'),
38+
pytest.param(zoneinfo.ZoneInfo('US/Arizona'), 'US/Arizona'),
4139
pytest.param(-6, 'Etc/GMT+6'),
4240
pytest.param(-11.0, 'Etc/GMT+11'),
4341
pytest.param(12, 'Etc/GMT-12'),
4442
],
4543
)
4644
def test_location_tz(tz, tz_expected):
4745
loc = Location(32.2, -111, tz)
48-
assert isinstance(loc.pytz, datetime.tzinfo) # Abstract base class.
49-
assert isinstance(loc.pytz, pytz.tzinfo.BaseTzInfo)
46+
assert isinstance(loc._zoneinfo, datetime.tzinfo) # Abstract base class.
5047
assert type(loc.tz) is str
5148
assert loc.tz == tz_expected
5249

5350

5451
def test_location_tz_update():
5552
loc = Location(32.2, -111, -11)
5653
assert loc.tz == 'Etc/GMT+11'
57-
assert loc.pytz == pytz.timezone('Etc/GMT+11') # Deprecated attribute.
5854

5955
# Updating Location's tz updates read-only time-zone attributes.
6056
loc.tz = 7
6157
assert loc.tz == 'Etc/GMT-7'
62-
assert loc.pytz == pytz.timezone('Etc/GMT-7') # Deprecated attribute.
6358

6459

6560
@pytest.mark.parametrize(
@@ -99,8 +94,8 @@ def test_location_print_all():
9994
assert tus.__str__() == expected_str
10095

10196

102-
def test_location_print_pytz():
103-
tus = Location(32.2, -111, pytz.timezone('US/Arizona'), 700, 'Tucson')
97+
def test_location_print():
98+
tus = Location(32.2, -111, zoneinfo.ZoneInfo('US/Arizona'), 700, 'Tucson')
10499
expected_str = '\n'.join([
105100
'Location: ',
106101
' name: Tucson',

tests/test_solarposition.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
import calendar
22
import datetime
3+
import math
34
import warnings
5+
import zoneinfo
6+
from datetime import timezone
47

58
import numpy as np
69
import pandas as pd
7-
8-
from .conftest import assert_frame_equal, assert_series_equal
9-
from numpy.testing import assert_allclose
1010
import pytest
11-
import pytz
11+
from numpy.testing import assert_allclose
1212

13-
from pvlib.location import Location
1413
from pvlib import solarposition, spa
14+
from pvlib.location import Location
1515

16-
from .conftest import (
17-
requires_ephem, requires_spa_c, requires_numba, requires_pandas_2_0
18-
)
16+
from .conftest import (assert_frame_equal, assert_series_equal, requires_ephem,
17+
requires_numba, requires_pandas_2_0, requires_spa_c)
1918

2019
# setup times and locations to be tested.
2120
times = pd.date_range(start=datetime.datetime(2014, 6, 24),
@@ -343,29 +342,26 @@ def test_pyephem_physical_dst(expected_solpos, golden):
343342

344343
@requires_ephem
345344
def test_calc_time():
346-
import pytz
347-
import math
348345
# validation from USNO solar position calculator online
349346

350-
epoch = datetime.datetime(1970, 1, 1)
351-
epoch_dt = pytz.utc.localize(epoch)
347+
epoch = datetime.datetime(1970, 1, 1, tzinfo=timezone.utc)
352348

353349
loc = tus
354350
loc.pressure = 0
355-
actual_time = pytz.timezone(loc.tz).localize(
356-
datetime.datetime(2014, 10, 10, 8, 30))
357-
lb = pytz.timezone(loc.tz).localize(datetime.datetime(2014, 10, 10, tol))
358-
ub = pytz.timezone(loc.tz).localize(datetime.datetime(2014, 10, 10, 10))
351+
tz = zoneinfo.ZoneInfo(loc.tz)
352+
actual_time = datetime.datetime(2014, 10, 10, 8, 30, tzinfo=tz)
353+
lb = datetime.datetime(2014, 10, 10, tol, tzinfo=tz)
354+
ub = datetime.datetime(2014, 10, 10, 10, tzinfo=tz)
359355
alt = solarposition.calc_time(lb, ub, loc.latitude, loc.longitude,
360356
'alt', math.radians(24.7))
361357
az = solarposition.calc_time(lb, ub, loc.latitude, loc.longitude,
362358
'az', math.radians(116.3))
363-
actual_timestamp = (actual_time - epoch_dt).total_seconds()
359+
actual_timestamp = (actual_time - epoch).total_seconds()
364360

365361
assert_allclose((alt.replace(second=0, microsecond=0) -
366-
epoch_dt).total_seconds(), actual_timestamp)
362+
epoch).total_seconds(), actual_timestamp)
367363
assert_allclose((az.replace(second=0, microsecond=0) -
368-
epoch_dt).total_seconds(), actual_timestamp)
364+
epoch).total_seconds(), actual_timestamp)
369365

370366

371367
@requires_ephem
@@ -715,6 +711,15 @@ def test_hour_angle_with_tricky_timezones():
715711
# GH 2132
716712
# tests timezones that have a DST shift at midnight
717713

714+
try: # transitive dependency to pytz through pandas < 3
715+
import pytz
716+
_NonExistentTimeError = pytz.exceptions.NonExistentTimeError
717+
_AmbiguousTimeError = pytz.exceptions.AmbiguousTimeError
718+
except ImportError: # pragma: no cover
719+
# pandas 3.x dropped pytz; these are now raised as ValueError
720+
_NonExistentTimeError = ValueError
721+
_AmbiguousTimeError = ValueError
722+
718723
eot = np.array([-3.935172, -4.117227, -4.026295, -4.026295])
719724

720725
longitude = 70.6693
@@ -726,7 +731,7 @@ def test_hour_angle_with_tricky_timezones():
726731
]).tz_localize('America/Santiago', nonexistent='shift_forward')
727732

728733
with pytest.raises((
729-
pytz.exceptions.NonExistentTimeError, # pandas 1.x, 2.x
734+
_NonExistentTimeError, # pandas 1.x, 2.x
730735
ValueError, # pandas 3.x
731736
)):
732737
times.normalize()
@@ -743,7 +748,7 @@ def test_hour_angle_with_tricky_timezones():
743748
]).tz_localize('America/Havana', ambiguous=[True, True, False, False])
744749

745750
with pytest.raises((
746-
pytz.exceptions.AmbiguousTimeError, # pandas 1.x, 2.x
751+
_AmbiguousTimeError, # pandas 1.x, 2.x
747752
ValueError, # pandas 3.x
748753
)):
749754
solarposition.hour_angle(times, longitude, eot)

0 commit comments

Comments
 (0)