diff --git a/Changelog b/Changelog index 78ae9c1d..b75ac85f 100644 --- a/Changelog +++ b/Changelog @@ -1,6 +1,19 @@ -since version 1.2.1 -=================== +version 1.3.0 (not yet released) +================================ * zero pad years in strtime (issue #194) + * have `cftime.datetime` create calendar-specific instances that support + addition and subtraction (issue #198). Name of base class changed + from `cftime.datetime` to `cftime.datetime_base` since `cftime.datetime` + is not a factory function. Iif your are using + `isinstance(my_datetime, cftime.datetime` this will need to be changed + to `isinstance(my_datetime, cftime.datetime_base)`. To maintain backward + compatibility with older versions of cftime, use + ```python + try: + from cftime import datetime_base + except ImportError: + datetime_base = datetime + ``` version 1.2.1 (release tag v1.2.1rel) ===================================== diff --git a/cftime/__init__.py b/cftime/__init__.py index 7138316d..49198c68 100644 --- a/cftime/__init__.py +++ b/cftime/__init__.py @@ -1,6 +1,6 @@ from ._cftime import utime, JulianDayFromDate, DateFromJulianDay, UNIT_CONVERSION_FACTORS -from ._cftime import _parse_date, date2index, time2index -from ._cftime import datetime, real_datetime +from ._cftime import _parse_date, date2index, time2index, DATE_TYPES +from ._cftime import datetime, real_datetime, datetime_base from ._cftime import DatetimeNoLeap, DatetimeAllLeap, Datetime360Day, DatetimeJulian, \ DatetimeGregorian, DatetimeProlepticGregorian from ._cftime import microsec_units, millisec_units, \ diff --git a/cftime/_cftime.pyx b/cftime/_cftime.pyx index 0caa6641..c2520a62 100644 --- a/cftime/_cftime.pyx +++ b/cftime/_cftime.pyx @@ -53,7 +53,7 @@ cdef int32_t* days_per_month_array = [ _rop_lookup = {Py_LT: '__gt__', Py_LE: '__ge__', Py_EQ: '__eq__', Py_GT: '__lt__', Py_GE: '__le__', Py_NE: '__ne__'} -__version__ = '1.2.1' +__version__ = '1.3.0' # Adapted from http://delete.me.uk/2005/03/iso8601.html # Note: This regex ensures that all ISO8601 timezone formats are accepted - but, due to legacy support for other timestrings, not all incorrect formats can be rejected. @@ -148,8 +148,8 @@ def _dateparse(timestr,calendar): pass if not basedate: if not utc_offset: - basedate = datetime(year, month, day, hour, minute, second, - microsecond) + basedate = datetime_base(year, month, day, hour, minute, second, + microsecond) else: raise ValueError('cannot use utc_offset for this reference date/calendar') if calendar in ['julian', 'standard', 'gregorian', 'proleptic_gregorian']: @@ -246,7 +246,7 @@ def date2num(dates,units,calendar='standard'): # remove time zone offset if getattr(date, 'tzinfo',None) is not None: date = date.replace(tzinfo=None) - date.utcoffset() - else: # convert date to same calendar specific cftime.datetime instance + else: # convert date to same calendar specific cftime.datetime_base instance if not isinstance(date, DATE_TYPES[calendar]): date = to_calendar_specific_datetime(date, calendar, False) if ismasked and mask.flat[n]: @@ -332,20 +332,20 @@ DATE_TYPES = { } -def to_calendar_specific_datetime(datetime, calendar, use_python_datetime): +def to_calendar_specific_datetime(dt, calendar, use_python_datetime): if use_python_datetime: date_type = real_datetime else: date_type = DATE_TYPES[calendar] return date_type( - datetime.year, - datetime.month, - datetime.day, - datetime.hour, - datetime.minute, - datetime.second, - datetime.microsecond + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond ) @@ -435,7 +435,7 @@ def num2date( **only_use_cftime_datetimes**: if False, python datetime.datetime objects are returned from num2date where possible; if True dates which - subclass cftime.datetime are returned for all calendars. Default **True**. + subclass cftime.datetime_base are returned for all calendars. Default **True**. **only_use_python_datetimes**: always return python datetime.datetime objects and raise an error if this is not possible. Ignored unless @@ -498,7 +498,7 @@ def num2date( # Through np.timedelta64, convert integers scaled to have units of # microseconds to datetime.timedelta objects, the timedelta type compatible - # with all cftime.datetime objects. + # with all cftime.datetime_base objects. deltas = scaled_times.astype("timedelta64[us]").astype(timedelta) try: return basedate + deltas @@ -841,11 +841,37 @@ cdef to_tuple(dt): dt.second, dt.microsecond) @cython.embedsignature(True) -cdef class datetime(object): +def datetime(year, month, day, hour=0, minute=0, + second=0, microsecond=0, dayofwk=-1, + dayofyr = -1, calendar='standard'): + """ +Create a calendar specific datetime instance. + """ + if calendar == '': + return datetime_base(year,month,day,hour,minute,second,microsecond,dayofwk,dayofyr,calendar=calendar) + else: + date_type = DATE_TYPES[calendar] + return date_type(year,month,day,hour,minute,second,microsecond,dayofwk,dayofyr) + +@cython.embedsignature(True) +cdef class datetime_base(object): """ The base class implementing most methods of datetime classes that mimic datetime.datetime but support calendars other than the proleptic -Gregorial calendar. +Gregorian calendar. The factory function cftime.datetime should be used +to create calendar-specific sub-class instances. + +Calendar specific sub-classes support timedelta operations by overloading +/-. +Comparisons with other datetime_base sub-class instances using the same +calendar are supported. +Comparison with native python datetime instances is possible +for cftime.datetime_base sub-class instances using +'gregorian' and 'proleptic_gregorian' calendars. + +Has isoformat, strftime, timetuple, replace, dayofwk, dayofyr, daysinmonth, +__repr__, and __str__ methods. +The default format of the string produced by strftime is controlled by self.format +(default %Y-%m-%d %H:%M:%S). """ cdef readonly int year, month, day, hour, minute cdef readonly int second, microsecond @@ -854,7 +880,7 @@ Gregorial calendar. # Python's datetime.datetime uses the proleptic Gregorian # calendar. This boolean is used to decide whether a - # cftime.datetime instance can be converted to + # cftime.datetime_base instance can be converted to # datetime.datetime. cdef readonly bint datetime_compatible @@ -997,9 +1023,9 @@ Gregorial calendar. self.second, self.microsecond) def __richcmp__(self, other, int op): - cdef datetime dt, dt_other + cdef datetime_base dt, dt_other dt = self - if isinstance(other, datetime): + if isinstance(other, datetime_base): dt_other = other # comparing two datetime instances if dt.calendar == dt_other.calendar: @@ -1054,11 +1080,11 @@ Gregorial calendar. return NotImplemented def __add__(self, other): - cdef datetime dt - if isinstance(self, datetime) and isinstance(other, timedelta): + cdef datetime_base dt + if isinstance(self, datetime_base) and isinstance(other, timedelta): dt = self delta = other - elif isinstance(self, timedelta) and isinstance(other, datetime): + elif isinstance(self, timedelta) and isinstance(other, datetime_base): dt = other delta = self else: @@ -1066,10 +1092,10 @@ Gregorial calendar. return dt._add_timedelta(delta) def __sub__(self, other): - cdef datetime dt - if isinstance(self, datetime): # left arg is a datetime instance + cdef datetime_base dt + if isinstance(self, datetime_base): # left arg is a datetime instance dt = self - if isinstance(other, datetime): + if isinstance(other, datetime_base): # datetime - datetime if dt.calendar != other.calendar: raise ValueError("cannot compute the time difference between dates with different calendars") @@ -1113,13 +1139,17 @@ datetime object.""" return NotImplemented @cython.embedsignature(True) -cdef class DatetimeNoLeap(datetime): +cdef class DatetimeNoLeap(datetime_base): """ Phony datetime object which mimics the python datetime object, but uses the "noleap" ("365_day") calendar. + +The factory function cftime.datetime should be used +to create instances of this class using the `calendar` kwarg to specify +the calendar. """ def __init__(self, *args, **kwargs): - datetime.__init__(self, *args, **kwargs) + datetime_base.__init__(self, *args, **kwargs) self.calendar = "noleap" self.datetime_compatible = False assert_valid_date(self, no_leap, False, has_year_zero=True) @@ -1132,13 +1162,17 @@ but uses the "noleap" ("365_day") calendar. return _dpm[self.month-1] @cython.embedsignature(True) -cdef class DatetimeAllLeap(datetime): +cdef class DatetimeAllLeap(datetime_base): """ Phony datetime object which mimics the python datetime object, but uses the "all_leap" ("366_day") calendar. + +The factory function cftime.datetime should be used +to create instances of this class using the `calendar` kwarg to specify +the calendar. """ def __init__(self, *args, **kwargs): - datetime.__init__(self, *args, **kwargs) + datetime_base.__init__(self, *args, **kwargs) self.calendar = "all_leap" self.datetime_compatible = False assert_valid_date(self, all_leap, False, has_year_zero=True) @@ -1151,13 +1185,17 @@ but uses the "all_leap" ("366_day") calendar. return _dpm_leap[self.month-1] @cython.embedsignature(True) -cdef class Datetime360Day(datetime): +cdef class Datetime360Day(datetime_base): """ Phony datetime object which mimics the python datetime object, but uses the "360_day" calendar. + +The factory function cftime.datetime should be used +to create instances of this class using the `calendar` kwarg to specify +the calendar. """ def __init__(self, *args, **kwargs): - datetime.__init__(self, *args, **kwargs) + datetime_base.__init__(self, *args, **kwargs) self.calendar = "360_day" self.datetime_compatible = False assert_valid_date(self, no_leap, False, has_year_zero=True, is_360_day=True) @@ -1170,13 +1208,17 @@ but uses the "360_day" calendar. return _dpm_360[self.month-1] @cython.embedsignature(True) -cdef class DatetimeJulian(datetime): +cdef class DatetimeJulian(datetime_base): """ Phony datetime object which mimics the python datetime object, but uses the "julian" calendar. + +The factory function cftime.datetime should be used +to create instances of this class using the `calendar` kwarg to specify +the calendar. """ def __init__(self, *args, **kwargs): - datetime.__init__(self, *args, **kwargs) + datetime_base.__init__(self, *args, **kwargs) self.calendar = "julian" self.datetime_compatible = False assert_valid_date(self, is_leap_julian, False) @@ -1185,7 +1227,7 @@ but uses the "julian" calendar. return DatetimeJulian(*add_timedelta(self, delta, is_leap_julian, False, False)) @cython.embedsignature(True) -cdef class DatetimeGregorian(datetime): +cdef class DatetimeGregorian(datetime_base): """ Phony datetime object which mimics the python datetime object, but uses the mixed Julian-Gregorian ("standard", "gregorian") calendar. @@ -1197,9 +1239,13 @@ Instances using the date after 1582-10-15 can be compared to datetime.datetime instances and used to compute time differences (datetime.timedelta) by subtracting a DatetimeGregorian instance from a datetime.datetime instance or vice versa. + +The factory function cftime.datetime should be used +to create instances of this class using the `calendar` kwarg to specify +the calendar. """ def __init__(self, *args, **kwargs): - datetime.__init__(self, *args, **kwargs) + datetime_base.__init__(self, *args, **kwargs) self.calendar = "gregorian" # dates after 1582-10-15 can be converted to and compared to @@ -1214,25 +1260,17 @@ a datetime.datetime instance or vice versa. return DatetimeGregorian(*add_timedelta(self, delta, is_leap_gregorian, True, False)) @cython.embedsignature(True) -cdef class DatetimeProlepticGregorian(datetime): +cdef class DatetimeProlepticGregorian(datetime_base): """ Phony datetime object which mimics the python datetime object, but allows for dates that don't exist in the proleptic gregorian calendar. -Supports timedelta operations by overloading + and -. - -Has strftime, timetuple, replace, __repr__, and __str__ methods. The -format of the string produced by __str__ is controlled by self.format -(default %Y-%m-%d %H:%M:%S). Supports comparisons with other -datetime instances using the same calendar; comparison with -native python datetime instances is possible for cftime.datetime -instances using 'gregorian' and 'proleptic_gregorian' calendars. - -Instance variables are year,month,day,hour,minute,second,microsecond,dayofwk,dayofyr, -format, and calendar. +The factory function cftime.datetime should be used +to create instances of this class using the `calendar` kwarg to specify +the calendar. """ def __init__(self, *args, **kwargs): - datetime.__init__(self, *args, **kwargs) + datetime_base.__init__(self, *args, **kwargs) self.calendar = "proleptic_gregorian" self.datetime_compatible = True assert_valid_date(self, is_leap_proleptic_gregorian, False) @@ -1261,7 +1299,7 @@ cdef _findall(text, substr): # calendar. ;) -cdef _strftime(datetime dt, fmt): +cdef _strftime(datetime_base dt, fmt): if _illegal_s.search(fmt): raise TypeError("This strftime implementation does not handle %s") # don't use strftime method at all. @@ -1321,7 +1359,7 @@ cdef int * month_lengths(bint (*is_leap)(int), int year): else: return _dpm -cdef void assert_valid_date(datetime dt, bint (*is_leap)(int), +cdef void assert_valid_date(datetime_base dt, bint (*is_leap)(int), bint julian_gregorian_mixed, bint has_year_zero=False, bint is_360_day=False) except *: @@ -1356,7 +1394,7 @@ cdef void assert_valid_date(datetime dt, bint (*is_leap)(int), if dt.microsecond < 0 or dt.microsecond > 999999: raise ValueError("invalid microsecond provided in {0!r}".format(dt)) -# Add a datetime.timedelta to a cftime.datetime instance. Uses +# Add a datetime.timedelta to a cftime.datetime_base instance. Uses # integer arithmetic to avoid rounding errors and preserve # microsecond accuracy. # @@ -1370,7 +1408,7 @@ cdef void assert_valid_date(datetime dt, bint (*is_leap)(int), # The date of the transition from the Julian to Gregorian calendar and # the number of invalid dates are hard-wired (1582-10-4 is the last day # of the Julian calendar, after which follows 1582-10-15). -cdef tuple add_timedelta(datetime dt, delta, bint (*is_leap)(int), bint julian_gregorian_mixed, bint has_year_zero): +cdef tuple add_timedelta(datetime_base dt, delta, bint (*is_leap)(int), bint julian_gregorian_mixed, bint has_year_zero): cdef int microsecond, second, minute, hour, day, month, year cdef int delta_microseconds, delta_seconds, delta_days cdef int* month_length @@ -1444,13 +1482,13 @@ cdef tuple add_timedelta(datetime dt, delta, bint (*is_leap)(int), bint julian_g return (year, month, day, hour, minute, second, microsecond, -1, -1) -# Add a datetime.timedelta to a cftime.datetime instance with the 360_day calendar. +# Add a datetime.timedelta to a cftime.datetime_base instance with the 360_day calendar. # # Assumes that the 360_day,365_day and 366_day calendars (unlike the rest of supported # calendars) have the year 0. Also, there are no leap years and all # months are 30 days long, so we can compute month and year by using # "//" and "%". -cdef tuple add_timedelta_360_day(datetime dt, delta): +cdef tuple add_timedelta_360_day(datetime_base dt, delta): cdef int microsecond, second, minute, hour, day, month, year cdef int delta_microseconds, delta_seconds, delta_days @@ -1870,7 +1908,7 @@ def DateFromJulianDay(JD, calendar='standard', only_use_cftime_datetimes=True, if calendar='julian', Julian Day follows julian calendar. - If only_use_cftime_datetimes is set to True, then cftime.datetime + If only_use_cftime_datetimes is set to True, then cftime.datetime_base objects are returned for all calendars. Otherwise the datetime object is a native python datetime object if the date falls in the Gregorian calendar (i.e. calendar='proleptic_gregorian', or calendar = 'standard'/'gregorian' @@ -2121,7 +2159,7 @@ are: @keyword only_use_cftime_datetimes: if False, datetime.datetime objects are returned from num2date where possible; if True dates which subclass -cftime.datetime are returned for all calendars. Default True. +cftime.datetime_base are returned for all calendars. Default True. @keyword only_use_python_datetimes: always return python datetime.datetime objects and raise an error if this is not possible. Ignored unless diff --git a/test/test_cftime.py b/test/test_cftime.py index 3e68b5ee..dec1cf15 100644 --- a/test/test_cftime.py +++ b/test/test_cftime.py @@ -18,6 +18,10 @@ DatetimeGregorian, DatetimeJulian, DatetimeNoLeap, DatetimeProlepticGregorian, JulianDayFromDate, _parse_date, date2index, date2num, num2date, utime, UNIT_CONVERSION_FACTORS) +try: + from cftime import datetime_base +except ImportError: + datetime_base = datetime try: from datetime import timezone @@ -248,7 +252,7 @@ def test_tz_naive(self): # check date2num,num2date methods. # use datetime from cftime, since this date doesn't # exist in "normal" calendars. - d = datetimex(2000, 2, 30) + d = datetimex(2000, 2, 30, calendar='360_day') t1 = self.cdftime_360day.date2num(d) assert_almost_equal(t1, 360 * 400.) d2 = self.cdftime_360day.num2date(t1) @@ -819,6 +823,18 @@ def test_tz_naive(self): cal = 'proleptic_gregorian' dt2 = num2date(date2num(dt1, units, cal), units, cal) assert(dt1 == dt2) +# issue #198 - cftime.datetime creates calendar specific datetimes that +# support addition/subtraction of timedeltas. + dt = cftime.datetime(2020, 1, 1, calendar='') + assert(isinstance(dt, cftime.datetime_base)) + dt = cftime.datetime(2020, 1, 1, calendar="julian") + dt += timedelta(hours=1) + assert(str(dt) == '2020-01-01 01:00:00') + assert(isinstance(dt, cftime.DatetimeJulian)) + for cal in cftime.DATE_TYPES.keys(): + dt = cftime.datetime(2020, 1, 1, calendar=cal) + assert(isinstance(dt, cftime.DATE_TYPES[cal])) + assert(isinstance(dt, datetime_base)) class TestDate2index(unittest.TestCase): @@ -1390,8 +1406,7 @@ def test_parse_incorrect_unitstring(self): ValueError, cftime._cftime.date2num, datetime(1900, 1, 1, 0), datestr, 'standard') -_DATE_TYPES = [DatetimeNoLeap, DatetimeAllLeap, DatetimeJulian, Datetime360Day, - DatetimeGregorian, DatetimeProlepticGregorian] +_DATE_TYPES = cftime.DATE_TYPES.values() @pytest.fixture(params=_DATE_TYPES) @@ -1581,7 +1596,7 @@ def test_num2date_only_use_cftime_datetimes_post_gregorian( def test_repr(): #expected = 'cftime.datetime(2000-01-01 00:00:00)' - expected = 'cftime.datetime(2000, 1, 1, 0, 0, 0, 0)' + expected = 'cftime.DatetimeGregorian(2000, 1, 1, 0, 0, 0, 0)' assert repr(datetimex(2000, 1, 1)) == expected