Skip to content

Commit a8e5002

Browse files
spencerkclarkshoyer
authored andcommitted
Fix dayofweek and dayofyear attributes from dates generated by cftime_range (#2633)
* Add workaround for cftime issue 106 * Raise ImportError for too old a version of cftime * lint * Simplify version check logic * Fix test skipping logic
1 parent bc5558e commit a8e5002

File tree

7 files changed

+61
-10
lines changed

7 files changed

+61
-10
lines changed

doc/whats-new.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ Enhancements
5555
reprojection, see (:issue:`2588`).
5656
By `Scott Henderson <https://github.com/scottyhq>`_.
5757
- Like :py:class:`pandas.DatetimeIndex`, :py:class:`CFTimeIndex` now supports
58-
"dayofyear" and "dayofweek" accessors (:issue:`2597`). By `Spencer Clark
58+
"dayofyear" and "dayofweek" accessors (:issue:`2597`). Note this requires a
59+
version of cftime greater than 1.0.2. By `Spencer Clark
5960
<https://github.com/spencerkclark>`_.
6061
- The option ``'warn_for_unclosed_files'`` (False by default) has been added to
6162
allow users to enable a warning when files opened by xarray are deallocated

xarray/coding/cftime_offsets.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,11 @@ def _shift_months(date, months, day_option='start'):
204204
day = _days_in_month(reference)
205205
else:
206206
raise ValueError(day_option)
207-
return date.replace(year=year, month=month, day=day)
207+
# dayofwk=-1 is required to update the dayofwk and dayofyr attributes of
208+
# the returned date object in versions of cftime between 1.0.2 and
209+
# 1.0.3.4. It can be removed for versions of cftime greater than
210+
# 1.0.3.4.
211+
return date.replace(year=year, month=month, day=day, dayofwk=-1)
208212

209213

210214
class MonthBegin(BaseCFTimeOffset):

xarray/coding/cftimeindex.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import re
4545
import warnings
4646
from datetime import timedelta
47+
from distutils.version import LooseVersion
4748

4849
import numpy as np
4950
import pandas as pd
@@ -108,6 +109,11 @@ def _parse_iso8601_with_reso(date_type, timestr):
108109
replace[attr] = int(value)
109110
resolution = attr
110111

112+
# dayofwk=-1 is required to update the dayofwk and dayofyr attributes of
113+
# the returned date object in versions of cftime between 1.0.2 and
114+
# 1.0.3.4. It can be removed for versions of cftime greater than
115+
# 1.0.3.4.
116+
replace['dayofwk'] = -1
111117
return default.replace(**replace), resolution
112118

113119

@@ -150,11 +156,21 @@ def get_date_field(datetimes, field):
150156
return np.array([getattr(date, field) for date in datetimes])
151157

152158

153-
def _field_accessor(name, docstring=None):
159+
def _field_accessor(name, docstring=None, min_cftime_version='0.0'):
154160
"""Adapted from pandas.tseries.index._field_accessor"""
155161

156-
def f(self):
157-
return get_date_field(self._data, name)
162+
def f(self, min_cftime_version=min_cftime_version):
163+
import cftime
164+
165+
version = cftime.__version__
166+
167+
if LooseVersion(version) >= LooseVersion(min_cftime_version):
168+
return get_date_field(self._data, name)
169+
else:
170+
raise ImportError('The {!r} accessor requires a minimum '
171+
'version of cftime of {}. Found an '
172+
'installed version of {}.'.format(
173+
name, min_cftime_version, version))
158174

159175
f.__name__ = name
160176
f.__doc__ = docstring
@@ -209,8 +225,10 @@ class CFTimeIndex(pd.Index):
209225
microsecond = _field_accessor('microsecond',
210226
'The microseconds of the datetime')
211227
dayofyear = _field_accessor('dayofyr',
212-
'The ordinal day of year of the datetime')
213-
dayofweek = _field_accessor('dayofwk', 'The day of week of the datetime')
228+
'The ordinal day of year of the datetime',
229+
'1.0.2.1')
230+
dayofweek = _field_accessor('dayofwk', 'The day of week of the datetime',
231+
'1.0.2.1')
214232
date_type = property(get_date_type)
215233

216234
def __new__(cls, data, name=None):

xarray/tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ def LooseVersion(vstring):
7171
has_pynio, requires_pynio = _importorskip('Nio')
7272
has_pseudonetcdf, requires_pseudonetcdf = _importorskip('PseudoNetCDF')
7373
has_cftime, requires_cftime = _importorskip('cftime')
74+
has_cftime_1_0_2_1, requires_cftime_1_0_2_1 = _importorskip(
75+
'cftime', minversion='1.0.2.1')
7476
has_dask, requires_dask = _importorskip('dask')
7577
has_bottleneck, requires_bottleneck = _importorskip('bottleneck')
7678
has_rasterio, requires_rasterio = _importorskip('rasterio')

xarray/tests/test_accessors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ def times_3d(times):
161161
@pytest.mark.parametrize('field', ['year', 'month', 'day', 'hour',
162162
'dayofyear', 'dayofweek'])
163163
def test_field_access(data, field):
164+
if field == 'dayofyear' or field == 'dayofweek':
165+
pytest.importorskip('cftime', minversion='1.0.2.1')
164166
result = getattr(data.time.dt, field)
165167
expected = xr.DataArray(
166168
getattr(xr.coding.cftimeindex.CFTimeIndex(data.time.values), field),
@@ -176,6 +178,8 @@ def test_field_access(data, field):
176178
def test_dask_field_access_1d(data, field):
177179
import dask.array as da
178180

181+
if field == 'dayofyear' or field == 'dayofweek':
182+
pytest.importorskip('cftime', minversion='1.0.2.1')
179183
expected = xr.DataArray(
180184
getattr(xr.coding.cftimeindex.CFTimeIndex(data.time.values), field),
181185
name=field, dims=['time'])
@@ -193,6 +197,8 @@ def test_dask_field_access_1d(data, field):
193197
def test_dask_field_access(times_3d, data, field):
194198
import dask.array as da
195199

200+
if field == 'dayofyear' or field == 'dayofweek':
201+
pytest.importorskip('cftime', minversion='1.0.2.1')
196202
expected = xr.DataArray(
197203
getattr(xr.coding.cftimeindex.CFTimeIndex(times_3d.values.ravel()),
198204
field).reshape(times_3d.shape),

xarray/tests/test_cftime_offsets.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from itertools import product
22

33
import numpy as np
4+
import pandas as pd
45
import pytest
56

67
from xarray import CFTimeIndex
@@ -797,3 +798,19 @@ def test_calendar_year_length(
797798
result = cftime_range(start, end, freq='D', closed='left',
798799
calendar=calendar)
799800
assert len(result) == expected_number_of_days
801+
802+
803+
@pytest.mark.parametrize('freq', ['A', 'M', 'D'])
804+
def test_dayofweek_after_cftime_range(freq):
805+
pytest.importorskip('cftime', minversion='1.0.2.1')
806+
result = cftime_range('2000-02-01', periods=3, freq=freq).dayofweek
807+
expected = pd.date_range('2000-02-01', periods=3, freq=freq).dayofweek
808+
np.testing.assert_array_equal(result, expected)
809+
810+
811+
@pytest.mark.parametrize('freq', ['A', 'M', 'D'])
812+
def test_dayofyear_after_cftime_range(freq):
813+
pytest.importorskip('cftime', minversion='1.0.2.1')
814+
result = cftime_range('2000-02-01', periods=3, freq=freq).dayofyear
815+
expected = pd.date_range('2000-02-01', periods=3, freq=freq).dayofyear
816+
np.testing.assert_array_equal(result, expected)

xarray/tests/test_cftimeindex.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
_parsed_string_to_bounds, assert_all_valid_date_type, parse_iso8601)
1313
from xarray.tests import assert_array_equal, assert_identical
1414

15-
from . import has_cftime, has_cftime_or_netCDF4, raises_regex, requires_cftime
15+
from . import (has_cftime, has_cftime_1_0_2_1, has_cftime_or_netCDF4,
16+
raises_regex, requires_cftime)
1617
from .test_coding_times import (
1718
_ALL_CALENDARS, _NON_STANDARD_CALENDARS, _all_cftime_date_types)
1819

@@ -175,14 +176,16 @@ def test_cftimeindex_field_accessors(index, field, expected):
175176
assert_array_equal(result, expected)
176177

177178

178-
@pytest.mark.skipif(not has_cftime, reason='cftime not installed')
179+
@pytest.mark.skipif(not has_cftime_1_0_2_1,
180+
reason='cftime not installed')
179181
def test_cftimeindex_dayofyear_accessor(index):
180182
result = index.dayofyear
181183
expected = [date.dayofyr for date in index]
182184
assert_array_equal(result, expected)
183185

184186

185-
@pytest.mark.skipif(not has_cftime, reason='cftime not installed')
187+
@pytest.mark.skipif(not has_cftime_1_0_2_1,
188+
reason='cftime not installed')
186189
def test_cftimeindex_dayofweek_accessor(index):
187190
result = index.dayofweek
188191
expected = [date.dayofwk for date in index]

0 commit comments

Comments
 (0)