[Git][debian-gis-team/cftime][master] 4 commits: New upstream version 1.6.2

Bas Couwenberg (@sebastic) gitlab at salsa.debian.org
Mon Sep 19 04:35:52 BST 2022



Bas Couwenberg pushed to branch master at Debian GIS Project / cftime


Commits:
ca6cc7bd by Bas Couwenberg at 2022-09-19T05:26:57+02:00
New upstream version 1.6.2
- - - - -
36d6e531 by Bas Couwenberg at 2022-09-19T05:26:59+02:00
Update upstream source from tag 'upstream/1.6.2'

Update to upstream version '1.6.2'
with Debian dir 4db8468d2b402fd4a791904c77c688979677cf60
- - - - -
04c6ea52 by Bas Couwenberg at 2022-09-19T05:28:05+02:00
New upstream release.

- - - - -
14ef9aec by Bas Couwenberg at 2022-09-19T05:28:57+02:00
Set distribution to unstable.

- - - - -


7 changed files:

- + .github/workflows/cibuildwheel.yml
- Changelog
- README.md
- debian/changelog
- src/cftime/_cftime.pyx
- + src/cftime/_strptime.py
- test/test_cftime.py


Changes:

=====================================
.github/workflows/cibuildwheel.yml
=====================================
@@ -0,0 +1,88 @@
+name: Wheels
+
+on:
+  pull_request:
+
+  push:
+    tags:
+      - "v*"
+
+jobs:
+  build_bdist:
+    name: "Build ${{ matrix.os }} (${{ matrix.arch }}) wheels"
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: ["ubuntu-latest", "windows-latest", "macos-latest"]
+        arch: ["x86_64", "arm64", "AMD64"]
+        exclude:
+        - os: ubuntu-latest
+          arch: arm64
+        - os: ubuntu-latest
+          arch: AMD64
+        - os: windows-latest
+          arch: arm64
+        - os: windows-latest
+          arch: x86_64
+        - os: macos-latest
+          arch: AMD64
+
+    steps:
+    - uses: actions/checkout at v3
+      with:
+        fetch-depth: 0
+
+    - name: "Building ${{ matrix.os }} (${{ matrix.arch }}) wheels"
+      uses: pypa/cibuildwheel at v2.9.0
+      env:
+        # Skips pypy and musllinux for now.
+        CIBW_SKIP: "pp* cp36-* cp37-* *-musllinux*"
+        CIBW_ARCHS: ${{ matrix.arch }}
+        CIBW_BUILD_FRONTEND: build
+        CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014
+        CIBW_TEST_SKIP: "*_arm64"
+        CIBW_TEST_REQUIRES: pytest
+        CIBW_TEST_COMMAND: >
+          python -c "import cftime; print(f'cftime v{cftime.__version__}')" &&
+          python -m pip install -r {package}/requirements-dev.txt &&
+          python -m pytest -vv {package}/test
+
+    - uses: actions/upload-artifact at v3
+      with:
+        name: pypi-artifacts
+        path: ${{ github.workspace }}/wheelhouse/*.whl
+
+
+  show-artifacts:
+    needs: [build_bdist]
+    name: "Show artifacts"
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/download-artifact at v3
+      with:
+        name: pypi-artifacts
+        path: ${{ github.workspace }}/dist
+
+    - shell: bash
+      run: |
+        ls -l ${{ github.workspace }}/dist
+
+
+  publish-artifacts-pypi:
+    needs: [build_bdist]
+    name: "Publish to PyPI"
+    runs-on: ubuntu-latest
+    # upload to PyPI for every tag starting with 'v'
+    if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
+    steps:
+    - uses: actions/download-artifact at v3
+      with:
+        name: pypi-artifacts
+        path: ${{ github.workspace }}/dist
+
+    - uses: pypa/gh-action-pypi-publish at release/v1
+      with:
+        user: __token__
+        password: ${{ secrets.PYPI_PASSWORD }}
+        print_hash: true


=====================================
Changelog
=====================================
@@ -1,3 +1,13 @@
+version 1.6.2 (release tag v1.6.2rel)
+=====================================
+ * num2date should not fail on an empty integer array (issue #287).
+ * longdouble keyword in date2num so that a roundtrip from a time to a date
+   and back again does not lose microsecond precision when the units require
+   the times be encoded as floating point values (PR #284)
+ * added strptime method (issue #277).
+ * cibuildwheel wheel-building workflow added to github actions by @ocefpaf (triggers binary
+   wheel builds and uploads to pypi automatically when GH release created). PR #290.
+
 version 1.6.1 (release tag v1.6.1rel)
 =====================================
  * fix failing tests on windows with numpy 1.23.0 (issue #278)
@@ -13,18 +23,18 @@ version 1.6.0 (release tag v1.6.0rel)
 
 version 1.5.2 (release tag v1.5.2rel)
 =====================================
- * silently change calendar='gregorian' to 'standard' internally, 
+ * silently change calendar='gregorian' to 'standard' internally,
    since 'gregorian' deprecated in CF v1.9 (issue #256).
  * add "is_leap_year" function (issue #259).
  * wheels that work on Apple M1 (arm64) available on pypi.
-   
+
 version 1.5.1.1
 ===============
  * no code changes, just new binary wheels for python 3.10.
 
 version 1.5.1 (release tag v1.5.1.rel)
 ======================================
- * added support for "common_year" and "common_years" units for "noleap" 
+ * added support for "common_year" and "common_years" units for "noleap"
    and "365_day" calendars (issue #5, PR #246)
  * check consistency of year arg and has_year_zero kwarg in cftime.datetime
    (issue #248).  Also assume if has_year_zero not specified it should be True
@@ -39,7 +49,6 @@ version 1.5.1 (release tag v1.5.1.rel)
    for this calendar). Issue warning when trying to
    to create a cftime.datetime instance that is not allowed in CF (PR #238).
 
-
 version 1.5.0 (release tag v1.5.0.rel)
 ======================================
  * clean-up deprecated calendar specific subclasses (PR #231).
@@ -47,13 +56,13 @@ version 1.5.0 (release tag v1.5.0.rel)
    (via `cftime.datetime.__format__`) PR #232.
  * add support for astronomical year numbering (including year zero) for
    real-world calendars using 'has_year_zero' cftime.datetime kwarg (PR #234).
-   Default is False for 'real-world' calendars ('julian', 'gregorian'/'standard', 
+   Default is False for 'real-world' calendars ('julian', 'gregorian'/'standard',
    'proleptic_gregorian'). Ignored for idealized calendars like '360_day
    (they always have year zero).
- * add "change_calendar" cftime.datetime method to switch to another 
+ * add "change_calendar" cftime.datetime method to switch to another
    'real-world' calendar. Enable comparison of cftime.datetime instances
    with different 'real-world' calendars (using the new change_calendar method)
- * remove legacy `utime` class, and legacy `JulianDayFromDate` and 
+ * remove legacy `utime` class, and legacy `JulianDayFromDate` and
    `DateFromJulianDay` functions (replaced by `cftime.datetime.toordinal`
    and `cftime.datetime.fromordinal`).  PR #235.
  * Change ValueError to TypeError in __sub__ (issue #236, PR #236).
@@ -61,7 +70,7 @@ version 1.5.0 (release tag v1.5.0.rel)
 version 1.4.1 (release tag v1.4.1.rel)
 ======================================
  * Restore use of calendar-specific sub-classes in `cftime.num2date`,
-   `cftime.datetime.__add__`, and `cftime.datetime.__sub__`.  The use of them 
+   `cftime.datetime.__add__`, and `cftime.datetime.__sub__`.  The use of them
    will be removed in a later release.
  * add 'fromordinal' static method to create a cftime.datetime instance
    from a julian day ordinal and calendar (inverse of 'toordinal').
@@ -72,9 +81,9 @@ version 1.4.0 (release tag v1.4.0.rel)
    and times allow.  Previously this would only be true if the units were
    'microseconds' (PR #225).  In other circumstances, as before, `cftime.date2num`
    will return an array of floats.
- * Rewrite of julian day/calendar functions (_IntJulianDayToCalendar and 
+ * Rewrite of julian day/calendar functions (_IntJulianDayToCalendar and
    _IntJulianDayFromCalendar) to remove GPL'ed code.  cftime license
-   changed to MIT (to be consistent with netcdf4-python). 
+   changed to MIT (to be consistent with netcdf4-python).
  * Added datetime.toordinal() (returns julian day, kwarg 'fractional'
    can be used to include fractional day).
  * cftime.datetime no longer uses calendar-specific sub-classes.
@@ -100,7 +109,7 @@ version 1.3.0 (release tag v1.3.0rel)
    The calendar specific sub-classes are now deprecated, but remain for now
    as stubs that just instantiate the base class and override __repr__.
  * update regex in _cpdef _parse_date so reference years with more than four
-   digits can be handled. 
+   digits can be handled.
  * Change default calendar in cftime.date2num from 'standard' to None
    (calendar associated with first input datetime object is used).
  * add `cftime.datetime.tzinfo=None` for compatibility with python datetime
@@ -131,7 +140,6 @@ version 1.2.0 (release tag v1.2.0rel)
  * utime.date2num/utime.num2date now just call module level functions.
    JulianDayFromDate/DateFromJulianDay no longer used internally (PR #180).
 
-
 version 1.1.3 (release tag v1.1.3rel)
 =====================================
  * add isoformat method for compatibility with python datetime (issue #152).
@@ -146,33 +154,27 @@ version 1.1.2 (release tag v1.1.2rel)
 
 version 1.1.1.2 (release tag v1.1.1.2rel)
 =========================================
- * include pyproject.toml in MANIFEST.in so it gets 
+ * include pyproject.toml in MANIFEST.in so it gets
    included in source tarball (issue #154).
 
 version 1.1.1.1 (release tag v1.1.1.1rel)
 =========================================
- * Fix error installing with pip on python 3.8 by following 
+ * Fix error installing with pip on python 3.8 by following
    PEP 517 (issue #148, PR #149)
 
 version 1.1.1 (release tag v1.1.1rel)
 =====================================
-
  * fix microsecond formatting issue, ensure identical results
    computed for arrays and scales (issue #143, PR #146).
 
 version 1.1.0 (release tag v1.1.0rel)
 =====================================
-
  * improved exceptions for time differences (issue #128, PR #131).
-
  * fix intersphinx entries (issue #133, PR #133)
-
  * make only_use_cftime_datetimes=True by default, so cftime datetime
    instances are returned by default by num2date (instead of returning python
    datetime instances where possible). Issue #136, PR #135.
-
  * Add daysinmonth attribute (issue #137, PR #138).
-
  * If only_use_python_datetimes=True and only_use_cftime_datetimes=False,
    num2date only returns python datetime instances and raises an exception
    if this is not possible.  num2pydate convenience function added which just calls
@@ -180,12 +182,10 @@ version 1.1.0 (release tag v1.1.0rel)
    only_use_cftime_datetimes=False.
    Remove positive times check, raise ValueError if python datetime
    tries to compute a date before MINYEAR (issue #134, PR #139)
-
  * Fix for fractional seconds in reference date in units string (issue #140,
    PR # 141).
 
 version 1.0.4.2 release
 =======================
-
- * fix for issue #126 (date2num error when converting a DatetimeProlepticGregorian 
+ * fix for issue #126 (date2num error when converting a DatetimeProlepticGregorian
    object). PR #127.


=====================================
README.md
=====================================
@@ -11,6 +11,9 @@ Time-handling functionality from netcdf4-python
 
 ## News
 For details on the latest updates, see the [Changelog](https://github.com/Unidata/cftime/blob/master/Changelog).
+ 
+9/18/2022:  Version 1.6.2 released.  strptime method added, fix for num2date failure on
+empty integer array, date2num 'longdouble' keyword added. New wheel building workflow.
 
 6/30/2022:  Version 1.6.1 released.  Fixes for numpy 1.23.0, updated CI/CD.
 


=====================================
debian/changelog
=====================================
@@ -1,3 +1,9 @@
+cftime (1.6.2-1) unstable; urgency=medium
+
+  * New upstream release.
+
+ -- Bas Couwenberg <sebastic at debian.org>  Mon, 19 Sep 2022 05:28:47 +0200
+
 cftime (1.6.1-1) unstable; urgency=medium
 
   * New upstream release.


=====================================
src/cftime/_cftime.pyx
=====================================
@@ -8,12 +8,11 @@ from numpy cimport int64_t, int32_t
 import cython
 import numpy as np
 import re
-import sys
 import time
 from datetime import datetime as datetime_python
 from datetime import timedelta, MINYEAR, MAXYEAR
-import time                     # strftime
 import warnings
+from ._strptime import _strptime
 
 microsec_units = ['microseconds','microsecond', 'microsec', 'microsecs']
 millisec_units = ['milliseconds', 'millisecond', 'millisec', 'millisecs', 'msec', 'msecs', 'ms']
@@ -38,7 +37,7 @@ cdef int[12] _dayspermonth_leap = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 3
 cdef int[13] _cumdayspermonth = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]
 cdef int[13] _cumdayspermonth_leap = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]
 
-__version__ = '1.6.1'
+__version__ = '1.6.2'
 
 # 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.
@@ -101,7 +100,11 @@ def _dateparse(timestr,calendar,has_year_zero=None):
             raise ValueError("'%s' units only allowed for '365_day' and 'noleap' calendars" % units) 
         else:
             raise ValueError(
-            "units must be one of 'seconds', 'minutes', 'hours' or 'days' (or singular version of these), got '%s'" % units)
+            "In general, units must be one of 'microseconds', 'milliseconds', "
+            "'seconds', 'minutes', 'hours', or 'days' (or select abbreviated "
+            "versions of these).  For the '360_day' calendar, "
+            "'months' can also be used, or for the 'noleap' calendar 'common_years' "
+            "can also be used. Got '%s' instead, which are not recognized." % units)
     # parse the date string.
     year, month, day, hour, minute, second, microsecond, utc_offset =\
         _parse_date( isostring.strip() )
@@ -122,12 +125,15 @@ def _dateparse(timestr,calendar,has_year_zero=None):
     return basedate
 
 def _can_use_python_datetime(date,calendar):
-    gregorian = datetime(1582,10,15,calendar=calendar,has_year_zero=date.has_year_zero)
-    return ((calendar == 'proleptic_gregorian' and date.year >= MINYEAR and date.year <= MAXYEAR) or \
-           (calendar in ['gregorian','standard'] and date > gregorian and date.year <= MAXYEAR))
+    #gregorian = datetime(1582,10,15,calendar=calendar,has_year_zero=date.has_year_zero)
+    #return ((calendar == 'proleptic_gregorian' and date.year >= MINYEAR and date.year <= MAXYEAR) or \
+    #       (calendar in ['gregorian','standard'] and date > gregorian and date.year <= MAXYEAR))
+    return  (calendar == 'proleptic_gregorian' and date.year >= MINYEAR and date.year <= MAXYEAR) or \
+            ((calendar in ['gregorian','standard'] and date.year <= MAXYEAR) and (date.year > 1582 or \
+            (date.year == 1582 and date.month >= 10 and date.day > 15)))
 
 @cython.embedsignature(True)
-def date2num(dates,units,calendar=None,has_year_zero=None):
+def date2num(dates, units, calendar=None, has_year_zero=None, longdouble=False):
     """
     Return numeric time values given datetime objects. The units
     of the numeric time values are described by the **units** argument
@@ -172,6 +178,12 @@ def date2num(dates,units,calendar=None,has_year_zero=None):
     This kwarg is not needed to define calendar systems allowed by CF
     (the calendar-specific defaults do this).
 
+    **longdouble**: If set True, output is in the long double float type
+    (numpy.float128) instead of float (numpy.float64), allowing microsecond
+    accuracy when converting a time value to a date and back again. Otherwise
+    this is only possible if the discretization of the time variable is an
+    integer multiple of the units.
+
     returns a numeric time value, or an array of numeric time values
     with approximately 1 microsecond accuracy.
     """
@@ -215,7 +227,7 @@ def date2num(dates,units,calendar=None,has_year_zero=None):
             has_year_zero = _year_zero_defaults(calendar)
 
     # if calendar is None or '', use calendar of first input cftime.datetime instances.
-    # if inputs are 'real' python datetime instances, use propleptic gregorian.
+    # if inputs are 'real' python datetime instances, use proleptic gregorian.
     if not calendar:
         if all_python_datetimes:
             calendar = 'proleptic_gregorian'
@@ -274,8 +286,25 @@ def date2num(dates,units,calendar=None,has_year_zero=None):
             quotient = np.int64(td // unit_timedelta)
             times.append(quotient)
         else:
-            times.append(td / unit_timedelta)
-                
+            if longdouble:
+                # Division of timedelta's is in float64 precision,
+                # i.e. losing microsecond precision.
+                # Conversion to float128 helps but can still lead to imprecision
+                # of +-1 microsecond in division:
+                #   quotient = (np.longdouble(td.total_seconds()) /
+                #               np.longdouble(unit_timedelta.total_seconds()))
+                # -> Convert to (64-bit) integers of microseconds
+                mtd = (td.days * 86400000000 +
+                       td.seconds * 1000000 +
+                       td.microseconds)
+                munit = (unit_timedelta.days * 86400000000 +
+                         unit_timedelta.seconds * 1000000 +
+                         unit_timedelta.microseconds)
+                quotient = np.longdouble(mtd) / np.longdouble(munit)
+            else:
+                quotient = td / unit_timedelta
+            times.append(quotient)
+
     if ismasked: # convert to masked array if input was masked array
         times = np.array(times, dtype=float)  # None -> nan
         times = np.ma.masked_invalid(times)
@@ -417,6 +446,8 @@ def scale_times(num, factor):
     if num.dtype.kind == "f":
         return factor * num
     else:
+        if num.size == 0: # empty array (issue #287)
+            return num
         # Python integers have arbitrary precision, so convert min and max
         # returned by NumPy functions through item, prior to multiplying by
         # factor.
@@ -666,7 +697,7 @@ def date2index(dates, nctime, calendar=None, select='exact', has_year_zero=None)
             has_year_zero = _year_zero_defaults(calendar)
 
     # if calendar is None or '', use calendar of first input cftime.datetime instances.
-    # if inputs are 'real' python datetime instances, use propleptic gregorian.
+    # if inputs are 'real' python datetime instances, use proleptic gregorian.
     if not calendar:
         d0 = dates_test.item(0)
         if isinstance(d0,datetime_python):
@@ -1208,6 +1239,48 @@ The default format of the string produced by strftime is controlled by self.form
             format = self.format
         return _strftime(self, format)
 
+    @staticmethod
+    def strptime(datestring, format, calendar='standard', has_year_zero=None):
+        """
+        Return a datetime corresponding to date_string, parsed according to format,
+        with a specified calendar and year zero convention.
+        The format directives 'y','Y','m','B','b','d','H','M','S' and 'f'
+        are supported for all calendars and dates.  If the date is valid
+        in the python 'proleptic_gregorian' calendar, then python's
+        datetime.strptime is used. For a complete list of formatting directives
+        supported in python's datetime.strptime, see section
+        'strftime() and strptime() Behavior' in the base Python documentation.
+        """
+        # if possible use python's datetime.strptime to get a python datetime instance
+        # (works for dates in proleptic_gregorian calendar)
+        fd = [d[0] for d in format.split('%') if d] # extract format descriptors
+        # calendar specific format descriptors that won't work will all calendars
+        special_fd = ['a', 'A', 'w', 'j', 'U', 'W', 'G', 'u', 'V']
+        try:
+            pydatetime = datetime_python.strptime(datestring, format)
+            # remove time zone offset
+            if getattr(pydatetime, 'tzinfo',None) is not None:
+                 pydatetime = pydatetime.replace(tzinfo=None) - pydatetime.utcoffset()
+            compatible_date =\
+            calendar == 'proleptic_gregorian' or \
+            (calendar in ['gregorian','standard'] and (pydatetime.year > 1582 or \
+             (pydatetime.year == 1582 and pydatetime.month > 10) or \
+             (pydatetime.year == 1582 and pydatetime.month == 10 and pydatetime.day > 15)))
+            if not compatible_date and any(x in special_fd for x in fd):
+                msg='one of the supplied format directives may not be consistent with the chosen calendar'
+                raise KeyError(msg)
+            # convert the cftime datetime instance
+            return datetime(pydatetime.year, pydatetime.month, pydatetime.day,
+                            pydatetime.hour, pydatetime.minute, pydatetime.second,
+                            pydatetime.microsecond, calendar=calendar, has_year_zero=has_year_zero)
+        # otherwise use a stripped-down version of C-python's _strptime.py
+        # (doesn't understand all possible formats, just
+        # 'y','Y','m','B','b','d','H','M','S' and 'f')
+        except ValueError:
+            year,month,day,hour,minute,second,microsecond = _strptime(datestring,format)
+            return datetime(year,month,day,hour,minute,second,microsecond,
+                            calendar=calendar,has_year_zero=has_year_zero)
+
     def __format__(self, format):
         # the string format "{t_obj}".format(t_obj=t_obj)
         # without an explicit format gives an empty string (format='')
@@ -1280,25 +1353,43 @@ The default format of the string produced by strftime is controlled by self.form
     def __str__(self):
         return self.isoformat(' ')
 
-    def isoformat(self,sep='T',timespec='auto'):
-        second = ":%02i" %self.second
-        if (timespec == 'auto' and self.microsecond) or timespec == 'microseconds':
-            second += ".%06i" % self.microsecond
-        if timespec == 'milliseconds':
-            millisecs = self.microsecond/1000
-            second += ".%03i" % millisecs
-        if timespec in ['auto', 'microseconds', 'milliseconds']:
-            return "%04i-%02i-%02i%s%02i:%02i%s" %\
-            (self.year, self.month, self.day, sep, self.hour, self.minute, second)
-        elif timespec == 'seconds':
-            return "%04i-%02i-%02i%s%02i:%02i:%02i" %\
-            (self.year, self.month, self.day, sep, self.hour, self.minute, self.second)
-        elif timespec == 'minutes':
-            return "%04i-%02i-%02i%s%02i:%02i" %\
-            (self.year, self.month, self.day, sep, self.hour, self.minute)
+    def isoformat(self, sep='T', timespec='auto'):
+        """
+        ISO date representation
+
+        """
+        if self.year < 0:
+            form0 = '{:05d}-{:02d}-{:02d}'
+        else:
+            form0 = '{:04d}-{:02d}-{:02d}'
+        if timespec == 'days':
+            form = form0
+            return form.format(self.year, self.month, self.day)
         elif timespec == 'hours':
-            return "%04i-%02i-%02i%s%02i" %\
-            (self.year, self.month, self.day, sep, self.hour)
+            form = form0 + '{:s}{:02d}'
+            return form.format(self.year, self.month, self.day, sep,
+                               self.hour)
+        elif timespec == 'minutes':
+            form = form0 + '{:s}{:02d}:{:02d}'
+            return form.format(self.year, self.month, self.day, sep,
+                               self.hour, self.minute)
+        elif timespec == 'seconds':
+            form = form0 + '{:s}{:02d}:{:02d}:{:02d}'
+            return form.format(self.year, self.month, self.day, sep,
+                               self.hour, self.minute, self.second)
+        elif timespec in ['auto', 'microseconds', 'milliseconds']:
+            second = '{:02d}'.format(self.second)
+            if timespec == 'milliseconds':
+                millisecs = int(round(self.microsecond / 1000, 0))
+                second += '.{:03d}'.format(millisecs)
+            elif timespec == 'microseconds':
+                second += '.{:06d}'.format(self.microsecond)
+            else:
+                if self.microsecond > 0:
+                    second += '.{:06d}'.format(self.microsecond)
+            form = form0 + '{:s}{:02d}:{:02d}:{:s}'
+            return form.format(self.year, self.month, self.day, sep,
+                               self.hour, self.minute, second)
         else:
             raise ValueError('illegal timespec')
 
@@ -1565,14 +1656,24 @@ cdef _findall(text, substr):
 # Every 28 years the calendar repeats, except through century leap
 # years where it's 6 years.  But only if you're using the Gregorian
 # calendar.  ;)
-
-
+# Make also 4-digit negative years
+# Allow .%f for microseconds
 cdef _strftime(datetime dt, fmt):
     if _illegal_s.search(fmt):
         raise TypeError("This strftime implementation does not handle %s")
     # don't use strftime method at all.
     # if dt.year > 1900:
     #    return dt.strftime(fmt)
+    if '%f' in fmt:
+        if not fmt.endswith('.%f'):
+            raise TypeError('If %f is used for microseconds it must be the'
+                            ' at the end as .%f')
+        else:
+            ihavems = True
+            fmt1 = fmt[:-3]
+    else:
+        ihavems = False
+        fmt1 = fmt
 
     year = dt.year
     # For every non-leap year century, advance by
@@ -1584,10 +1685,10 @@ cdef _strftime(datetime dt, fmt):
     # Move to around the year 2000
     year = year + ((2000 - year) // 28) * 28
     timetuple = dt.timetuple()
-    s1 = time.strftime(fmt, (year,) + timetuple[1:])
+    s1 = time.strftime(fmt1, (year,) + timetuple[1:])
     sites1 = _findall(s1, str(year))
 
-    s2 = time.strftime(fmt, (year + 28,) + timetuple[1:])
+    s2 = time.strftime(fmt1, (year + 28,) + timetuple[1:])
     sites2 = _findall(s2, str(year + 28))
 
     sites = []
@@ -1596,9 +1697,14 @@ cdef _strftime(datetime dt, fmt):
             sites.append(site)
 
     s = s1
-    syear = "%04d" % (dt.year,)
+    if dt.year < 0:
+        syear = "%05d" % (dt.year,)
+    else:
+        syear = "%04d" % (dt.year,)
     for site in sites:
         s = s[:site] + syear + s[site + 4:]
+    if ihavems:
+        s = s + '.{:06d}'.format(dt.microsecond)
     return s
 
 cdef bint is_leap_julian(int year, bint has_year_zero):


=====================================
src/cftime/_strptime.py
=====================================
@@ -0,0 +1,165 @@
+"""stripped-down version of _strptime.py from C python"""
+from re import compile as re_compile
+from re import IGNORECASE
+from re import escape as re_escape
+from _thread import allocate_lock as _thread_allocate_lock
+from calendar import month_name, month_abbr
+
+__all__ = []
+month_name = list(month_name)
+month_name = [m.lower() for m in month_name]
+month_abbr = list(month_abbr)
+month_abbr = [m.lower() for m in month_abbr]
+
+class TimeRE(dict):
+    """Handle conversion from format directives to regexes."""
+
+    def __init__(self):
+        """Create keys/values.
+
+        Order of execution is important for dependency reasons.
+
+        """
+        base = super()
+        base.__init__({
+            # The " [1-9]" part of the regex is to make %c from ANSI C work
+            'd': r"(?P<d>3[0-1]|[1-2]\d|0[1-9]|[1-9]| [1-9])",
+            'f': r"(?P<f>[0-9]{1,6})",
+            'H': r"(?P<H>2[0-3]|[0-1]\d|\d)",
+            'm': r"(?P<m>1[0-2]|0[1-9]|[1-9])",
+            'M': r"(?P<M>[0-5]\d|\d)",
+            'S': r"(?P<S>6[0-1]|[0-5]\d|\d)",
+            'y': r"(?P<y>\d\d)",
+#           'Y': r"(?P<Y>\d\d\d\d)",
+            'Y': r"(?P<Y>[+-]?[0-9]+)", # handle neg and > 4 digits
+            'B': self.__seqToRE(month_name[1:], 'B'),
+            'b': self.__seqToRE(month_abbr[1:], 'b'),
+            '%': '%'})
+
+    def __seqToRE(self, to_convert, directive):
+        """Convert a list to a regex string for matching a directive.
+
+        Want possible matching values to be from longest to shortest.  This
+        prevents the possibility of a match occurring for a value that also
+        a substring of a larger value that should have matched (e.g., 'abc'
+        matching when 'abcdef' should have been the match).
+
+        """
+        to_convert = sorted(to_convert, key=len, reverse=True)
+        for value in to_convert:
+            if value != '':
+                break
+        else:
+            return ''
+        regex = '|'.join(re_escape(stuff) for stuff in to_convert)
+        regex = '(?P<%s>%s' % (directive, regex)
+        return '%s)' % regex
+
+    def pattern(self, format):
+        """Return regex pattern for the format string.
+        Need to make sure that any characters that might be interpreted as
+        regex syntax are escaped.
+        """
+        processed_format = ''
+        # The sub() call escapes all characters that might be misconstrued
+        # as regex syntax.  Cannot use re.escape since we have to deal with
+        # format directives (%m, etc.).
+        regex_chars = re_compile(r"([\\.^$*+?\(\){}\[\]|])")
+        format = regex_chars.sub(r"\\\1", format)
+        whitespace_replacement = re_compile(r'\s+')
+        format = whitespace_replacement.sub(r'\\s+', format)
+        while '%' in format:
+            directive_index = format.index('%')+1
+            processed_format = "%s%s%s" % (processed_format,
+                                           format[:directive_index-1],
+                                           self[format[directive_index]])
+            format = format[directive_index+1:]
+        return "%s%s" % (processed_format, format)
+
+    def compile(self, format):
+        """Return a compiled re object for the format string."""
+        return re_compile(self.pattern(format), IGNORECASE)
+
+_cache_lock = _thread_allocate_lock()
+# DO NOT modify _TimeRE_cache or _regex_cache without acquiring the cache lock
+# first!
+_TimeRE_cache = TimeRE()
+_CACHE_MAX_SIZE = 5 # Max number of regexes stored in _regex_cache
+_regex_cache = {}
+
+def _strptime(data_string, format):
+    """Return a 7-tuple consisting of the data required to construct a
+    datetime based on the input string and the format string."""
+
+    for index, arg in enumerate([data_string, format]):
+        if not isinstance(arg, str):
+            msg = "strptime() argument {} must be str, not {}"
+            raise TypeError(msg.format(index, type(arg)))
+
+    global _TimeRE_cache, _regex_cache
+    with _cache_lock:
+        if len(_regex_cache) > _CACHE_MAX_SIZE:
+            _regex_cache.clear()
+        format_regex = _regex_cache.get(format)
+        if not format_regex:
+            try:
+                format_regex = _TimeRE_cache.compile(format)
+            # KeyError raised when a bad format is found; can be specified as
+            # \\, in which case it was a stray % but with a space after it
+            except KeyError as err:
+                bad_directive = err.args[0]
+                if bad_directive == "\\":
+                    bad_directive = "%"
+                del err
+                if bad_directive in ['I','a','A','w','j','u','U','V','W','G']:
+                    msg="'%s' directive not supported for dates not valid in python %s calendar"
+                    raise ValueError(msg %
+                            (bad_directive,'proleptic_gregorian'))
+                else:
+                    raise ValueError("'%s' is a bad directive in format '%s'" %
+                                    (bad_directive, format)) from None
+            # IndexError only occurs when the format string is "%"
+            except IndexError:
+                raise ValueError("stray %% in format '%s'" % format) from None
+            _regex_cache[format] = format_regex
+    found = format_regex.match(data_string)
+    if not found:
+        raise ValueError("time data %r does not match format %r" %
+                         (data_string, format))
+    if len(data_string) != found.end():
+        raise ValueError("unconverted data remains: %s" %
+                          data_string[found.end():])
+
+    month = day = 1
+    hour = minute = second = fraction = 0
+    found_dict = found.groupdict()
+    for group_key in found_dict.keys():
+        if group_key == 'y':
+            year = int(found_dict['y'])
+            if year <= 68:
+                year += 2000
+            else:
+                year += 1900
+        elif group_key == 'Y':
+            year = int(found_dict['Y'])
+        elif group_key == 'm':
+            month = int(found_dict['m'])
+        elif group_key == 'B':
+            month = month_name.index(found_dict['B'].lower())
+        elif group_key == 'b':
+            month = month_abbr.index(found_dict['b'].lower())
+        elif group_key == 'd':
+            day = int(found_dict['d'])
+        elif group_key == 'H':
+            hour = int(found_dict['H'])
+        elif group_key == 'M':
+            minute = int(found_dict['M'])
+        elif group_key == 'S':
+            second = int(found_dict['S'])
+        elif group_key == 'f':
+            s = found_dict['f']
+            # Pad to always return microseconds.
+            s += "0" * (6 - len(s))
+            fraction = int(s)
+
+    return year,month,day,hour,minute,second,fraction


=====================================
test/test_cftime.py
=====================================
@@ -732,7 +732,7 @@ class cftimeTestCase(unittest.TestCase):
         # issue #140 (fractional seconds in reference date)
         d = datetime.strptime('2018-01-23 09:27:10.950000',"%Y-%m-%d %H:%M:%S.%f")
         units = 'seconds since 2018-01-23 09:31:42.94'
-        assert(cftime.date2num(d, units) == -271.99)
+        assert(float(cftime.date2num(d, units)) == -271.99)
         # issue 143 - same answer for arrays vs scalars.
         units = 'seconds since 1970-01-01 00:00:00'
         times_in = [1261440000.0, 1261440001.0, 1261440002.0, 1261440003.0,
@@ -751,7 +751,7 @@ class cftimeTestCase(unittest.TestCase):
         # issue #152 add isoformat()
         assert(d.isoformat()[0:24] == '2009-12-22T00:00:00.0156')
         assert(d.isoformat(sep=' ')[0:24] == '2009-12-22 00:00:00.0156')
-        assert(d.isoformat(sep=' ',timespec='milliseconds') == '2009-12-22 00:00:00.015')
+        assert(d.isoformat(sep=' ',timespec='milliseconds') == '2009-12-22 00:00:00.016')
         assert(d.isoformat(sep=' ',timespec='seconds') == '2009-12-22 00:00:00')
         assert(d.isoformat(sep=' ',timespec='minutes') == '2009-12-22 00:00')
         assert(d.isoformat(sep=' ',timespec='hours') == '2009-12-22 00')
@@ -921,6 +921,9 @@ class cftimeTestCase(unittest.TestCase):
         assert(not cftime.is_leap_year(1,calendar='standard',has_year_zero=True))
         assert(not cftime.is_leap_year(1,calendar='365_day'))
         assert(cftime.is_leap_year(1,calendar='366_day'))
+        # num2date should not fail on an empty int array (issue #287)
+        d = cftime.num2date(np.array([], dtype="int64"), "days since 1970-01-01",\
+            calendar="proleptic_gregorian", only_use_cftime_datetimes=True)
 
 
 class TestDate2index(unittest.TestCase):
@@ -1686,6 +1689,59 @@ def test_string_format():
     assert dt.strftime('%H%m%d') == '{0:%H%m%d}'.format(dt)
     assert 'the year is 2000' == 'the year is {dt:%Y}'.format(dt=dt)
 
+
+def test_string_format2():
+    dt = cftime.datetime(-4713, 1, 1, 12, 0, 0, 10)
+    # check a given format string acts like strftime
+    assert dt.strftime('%H%m%d') == '{0:%H%m%d}'.format(dt)
+    assert dt.strftime() == '-4713-01-01 12:00:00'
+    assert dt.strftime('%Y-%m-%d %H:%M:%S') == '-4713-01-01 12:00:00'
+    assert dt.strftime('%Y-%m-%d %H:%M:%S.%f') == '-4713-01-01 12:00:00.000010'
+    assert dt.strftime('%d.%m.%Y %H:%M:%S.%f') == '01.01.-4713 12:00:00.000010'
+    dt = cftime.datetime(-713, 1, 1, 12, 0, 0, 10)
+    assert dt.strftime('%H%m%d') == '{0:%H%m%d}'.format(dt)
+    assert dt.strftime() == '-0713-01-01 12:00:00'
+    assert dt.strftime('%Y-%m-%d %H:%M:%S') == '-0713-01-01 12:00:00'
+    assert dt.strftime('%Y-%m-%d %H:%M:%S.%f') == '-0713-01-01 12:00:00.000010'
+    assert dt.strftime('%d.%m.%Y %H:%M:%S.%f') == '01.01.-0713 12:00:00.000010'
+
+def test_strptime():
+    d = cftime.datetime.strptime('24/Aug/2004:17:57:26 +0200', '%d/%b/%Y:%H:%M:%S %z',calendar='julian',has_year_zero=True)
+    assert(repr(d) == "cftime.datetime(2004, 8, 24, 15, 57, 26, 0, calendar='julian', has_year_zero=True)")
+    d = cftime.datetime.strptime("0000-02-30",\
+             "%Y-%m-%d",calendar='360_day',has_year_zero=True)
+    assert(repr(d) == "cftime.datetime(0, 2, 30, 0, 0, 0, 0, calendar='360_day', has_year_zero=True)")
+    d = cftime.datetime.strptime('-99999-02-29 10:18:32.926',\
+             '%Y-%m-%d %H:%M:%S.%f',calendar='366_day')
+    assert(repr(d) == "cftime.datetime(-99999, 2, 29, 10, 18, 32, 926000, calendar='all_leap', has_year_zero=True)")
+    d = cftime.datetime.strptime('24/Aug/-4712:17:57:26', '%d/%b/%Y:%H:%M:%S',calendar='julian')
+    assert(repr(d) == "cftime.datetime(-4712, 8, 24, 17, 57, 26, 0, calendar='julian', has_year_zero=False)")
+    d = cftime.datetime.strptime('24/August/-4712:17:57:26', '%d/%B/%Y:%H:%M:%S',calendar='julian')
+    assert(repr(d) == "cftime.datetime(-4712, 8, 24, 17, 57, 26, 0, calendar='julian', has_year_zero=False)")
+    d = cftime.datetime.strptime("-4712", "%Y", calendar="julian")
+    assert(repr(d) == "cftime.datetime(-4712, 1, 1, 0, 0, 0, 0, calendar='julian', has_year_zero=False)")
+    # should fail with KeyError
+    try:
+        d=cftime.datetime.strptime("2000-45-3", "%G-%V-%u", calendar="noleap")
+    except KeyError:
+        pass
+    else:
+        raise AssertionError
+
+
+def test_string_isoformat():
+    dt = cftime.datetime(-4713, 1, 1, 12, 0, 0, 10)
+    assert dt.isoformat() == '-4713-01-01T12:00:00.000010'
+    assert dt.isoformat(' ', 'days') == '-4713-01-01'
+    assert dt.isoformat(' ', 'seconds') == '-4713-01-01 12:00:00'
+    assert dt.isoformat(' ', 'microseconds') == '-4713-01-01 12:00:00.000010'
+    dt = cftime.datetime(-713, 1, 1, 12, 0, 0, 10)
+    assert dt.isoformat() == '-0713-01-01T12:00:00.000010'
+    assert dt.isoformat(' ', 'days') == '-0713-01-01'
+    assert dt.isoformat(' ', 'seconds') == '-0713-01-01 12:00:00'
+    assert dt.isoformat(' ', 'microseconds') == '-0713-01-01 12:00:00.000010'
+
+
 def test_dayofyr_after_replace(date_type):
     date = date_type(1, 1, 1)
     assert date.dayofyr == 1
@@ -2048,11 +2104,16 @@ def test_date2num_num2date_roundtrip(encoding_units, freq, calendar):
         assert encoded.dtype == np.int64
         np.testing.assert_equal(decoded, times)
     else:
+        # if sys.platform.startswith("win"):
+        #     assert encoded.dtype == np.float64
+        # else:
+        #     assert encoded.dtype == np.float128
         assert encoded.dtype == np.float64
         tolerance = timedelta(microseconds=2000)
         meets_tolerance = np.abs(decoded - times) <= tolerance
         assert np.all(meets_tolerance)
 
+
 def test_date2num_missing_data():
     # Masked array
     a = [
@@ -2064,7 +2125,7 @@ def test_date2num_missing_data():
     mask = [True, False, True, False]
     array = np.ma.array(a, mask=mask)
     out = date2num(array, units="days since 2000-12-01", calendar="standard")
-    assert ((out == np.ma.array([-99, 1, -99, 3] , mask=mask)).all())
+    assert ((out == np.ma.array([-99, 1, -99, 3], mask=mask)).all())
     assert ((out.mask == mask).all())
 
     # Scalar masked array
@@ -2101,5 +2162,58 @@ def test_num2date_empty_array():
     np.testing.assert_equal(result, expected)
 
 
+DATEPARSE_ERROR_TESTS = [
+    ("foo", "In general, units must be"),
+    ("months", "'months since' units only allowed"),
+    ("common_years", "'common_years' units only allowed")
+]
+
+
+ at pytest.mark.parametrize(("units", "match"), DATEPARSE_ERROR_TESTS)
+def test_num2date_unrecognized_units(units, match):
+    with pytest.raises(ValueError, match=match):
+        num2date(0.0, units=f"{units} since 2000-01-01", calendar="standard")
+
+
+ at pytest.mark.parametrize(("units", "match"), DATEPARSE_ERROR_TESTS)
+def test_date2num_unrecognized_units(units, match):
+    date = cftime.datetime(2000, 1, 1, calendar="standard")
+    with pytest.raises(ValueError, match=match):
+        date2num(date, units=f"{units} since 2000-01-01", calendar="standard")
+
+
+def test_num2date_precision():
+    if sys.platform.startswith("win"):
+        pytest.skip("skipping tests that require float128 on windows")
+    testdates = [(1271, 3, 18, 19, 41, 33),
+                 (1271, 3, 18, 19, 41, 32, 999998)]
+    unitinc = ['microseconds', 'seconds', 'minutes', 'hours', 'days']
+    for cc in ['standard', 'gregorian', 'julian', 'proleptic_gregorian',
+               'noleap', 'all_leap', '365_day', '366_day', '360_day']:
+        for uinc in unitinc:
+            if cc in ['standard', 'gregorian', 'julian']:
+                units = uinc + ' since -4713-01-01 12:00:00'
+            elif cc in ['proleptic_gregorian']:
+                units = uinc + ' since -4714-01-01 12:00:00'
+            elif cc in ['noleap', 'all_leap', '365_day', '366_day', '360_day']:
+                units = uinc + ' since 0000-01-01 12:00:00'
+            # scalar
+            date = datetimex(*testdates[0], calendar=cc)
+            num = date2num(date, units, calendar=cc, longdouble=True)
+            date2 = num2date(num, units, calendar=cc)
+            assert date == date2
+            # array
+            date = [ datetimex(*dd, calendar=cc) for dd in testdates ]
+            num = date2num(date, units, calendar=cc, longdouble=True)
+            date2 = num2date(num, units, calendar=cc)
+            for i in range(len(date)):
+                assert date[i] == date2[i]
+            # masked array
+            num = np.ma.array(num, mask=(True, False))
+            date2 = num2date(num, units, calendar=cc)
+            assert np.ma.is_masked(date2[0])
+            assert date[1] == date2[1]
+
+
 if __name__ == '__main__':
     unittest.main()



View it on GitLab: https://salsa.debian.org/debian-gis-team/cftime/-/compare/ec1e403dc339e0b348e1a509c1da27eaada1f0ab...14ef9aec4a62c80ff0ffde413095fe3f569eb0b4

-- 
View it on GitLab: https://salsa.debian.org/debian-gis-team/cftime/-/compare/ec1e403dc339e0b348e1a509c1da27eaada1f0ab...14ef9aec4a62c80ff0ffde413095fe3f569eb0b4
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20220919/f6afee18/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list