1# Licensed under a 3-clause BSD style license - see LICENSE.rst
2"""
3Helpers to interact with the ERFA library, in particular for leap seconds.
4"""
5import functools
6import threading
7from datetime import datetime, timedelta
8from warnings import warn
9
10import numpy as np
11
12from .core import ErfaWarning
13
14from .ufunc import get_leap_seconds, set_leap_seconds, dt_eraLEAPSECOND
15
16
17_NotFound = object()
18
19
20# TODO: This can still be made to work for setters by implementing an
21# accompanying metaclass that supports it; we just don't need that right this
22# second
23class classproperty(property):
24    """
25    Similar to `property`, but allows class-level properties.  That is,
26    a property whose getter is like a `classmethod`.
27
28    The wrapped method may explicitly use the `classmethod` decorator (which
29    must become before this decorator), or the `classmethod` may be omitted
30    (it is implicit through use of this decorator).
31
32    .. note::
33
34        classproperty only works for *read-only* properties.  It does not
35        currently allow writeable/deletable properties, due to subtleties of how
36        Python descriptors work.  In order to implement such properties on a class
37        a metaclass for that class must be implemented.
38
39    Parameters
40    ----------
41    fget : callable
42        The function that computes the value of this property (in particular,
43        the function when this is used as a decorator) a la `property`.
44
45    doc : str, optional
46        The docstring for the property--by default inherited from the getter
47        function.
48
49    lazy : bool, optional
50        If True, caches the value returned by the first call to the getter
51        function, so that it is only called once (used for lazy evaluation
52        of an attribute).  This is analogous to `lazyproperty`.  The ``lazy``
53        argument can also be used when `classproperty` is used as a decorator
54        (see the third example below).  When used in the decorator syntax this
55        *must* be passed in as a keyword argument.
56
57    Examples
58    --------
59
60    ::
61
62        >>> class Foo:
63        ...     _bar_internal = 1
64        ...     @classproperty
65        ...     def bar(cls):
66        ...         return cls._bar_internal + 1
67        ...
68        >>> Foo.bar
69        2
70        >>> foo_instance = Foo()
71        >>> foo_instance.bar
72        2
73        >>> foo_instance._bar_internal = 2
74        >>> foo_instance.bar  # Ignores instance attributes
75        2
76
77    As previously noted, a `classproperty` is limited to implementing
78    read-only attributes::
79
80        >>> class Foo:
81        ...     _bar_internal = 1
82        ...     @classproperty
83        ...     def bar(cls):
84        ...         return cls._bar_internal
85        ...     @bar.setter
86        ...     def bar(cls, value):
87        ...         cls._bar_internal = value
88        ...
89        Traceback (most recent call last):
90        ...
91        NotImplementedError: classproperty can only be read-only; use a
92        metaclass to implement modifiable class-level properties
93
94    When the ``lazy`` option is used, the getter is only called once::
95
96        >>> class Foo:
97        ...     @classproperty(lazy=True)
98        ...     def bar(cls):
99        ...         print("Performing complicated calculation")
100        ...         return 1
101        ...
102        >>> Foo.bar
103        Performing complicated calculation
104        1
105        >>> Foo.bar
106        1
107
108    If a subclass inherits a lazy `classproperty` the property is still
109    re-evaluated for the subclass::
110
111        >>> class FooSub(Foo):
112        ...     pass
113        ...
114        >>> FooSub.bar
115        Performing complicated calculation
116        1
117        >>> FooSub.bar
118        1
119    """
120
121    def __new__(cls, fget=None, doc=None, lazy=False):
122        if fget is None:
123            # Being used as a decorator--return a wrapper that implements
124            # decorator syntax
125            def wrapper(func):
126                return cls(func, lazy=lazy)
127
128            return wrapper
129
130        return super().__new__(cls)
131
132    def __init__(self, fget, doc=None, lazy=False):
133        self._lazy = lazy
134        if lazy:
135            self._lock = threading.RLock()   # Protects _cache
136            self._cache = {}
137        fget = self._wrap_fget(fget)
138
139        super().__init__(fget=fget, doc=doc)
140
141        # There is a buglet in Python where self.__doc__ doesn't
142        # get set properly on instances of property subclasses if
143        # the doc argument was used rather than taking the docstring
144        # from fget
145        # Related Python issue: https://bugs.python.org/issue24766
146        if doc is not None:
147            self.__doc__ = doc
148
149    def __get__(self, obj, objtype):
150        if self._lazy:
151            val = self._cache.get(objtype, _NotFound)
152            if val is _NotFound:
153                with self._lock:
154                    # Check if another thread initialised before we locked.
155                    val = self._cache.get(objtype, _NotFound)
156                    if val is _NotFound:
157                        val = self.fget.__wrapped__(objtype)
158                        self._cache[objtype] = val
159        else:
160            # The base property.__get__ will just return self here;
161            # instead we pass objtype through to the original wrapped
162            # function (which takes the class as its sole argument)
163            val = self.fget.__wrapped__(objtype)
164        return val
165
166    def getter(self, fget):
167        return super().getter(self._wrap_fget(fget))
168
169    def setter(self, fset):
170        raise NotImplementedError(
171            "classproperty can only be read-only; use a metaclass to "
172            "implement modifiable class-level properties")
173
174    def deleter(self, fdel):
175        raise NotImplementedError(
176            "classproperty can only be read-only; use a metaclass to "
177            "implement modifiable class-level properties")
178
179    @staticmethod
180    def _wrap_fget(orig_fget):
181        if isinstance(orig_fget, classmethod):
182            orig_fget = orig_fget.__func__
183
184        # Using stock functools.wraps instead of the fancier version
185        # found later in this module, which is overkill for this purpose
186
187        @functools.wraps(orig_fget)
188        def fget(obj):
189            return orig_fget(obj.__class__)
190
191        return fget
192
193
194class leap_seconds:
195    """Leap second management.
196
197    This singleton class allows access to ERFA's leap second table,
198    using the methods 'get', 'set', and 'update'.
199
200    One can also check expiration with 'expires' and 'expired'.
201
202    Note that usage of the class is similar to a ``ScienceState`` class,
203    but it cannot be used as a context manager.
204    """
205    _expires = None
206    """Explicit expiration date inferred from leap-second table."""
207    _expiration_days = 180
208    """Number of days beyond last leap second at which table expires."""
209
210    def __init__(self):
211        raise RuntimeError("This class is a singleton.  Do not instantiate.")
212
213    @classmethod
214    def get(cls):
215        """Get the current leap-second table used internally."""
216        return get_leap_seconds()
217
218    @classmethod
219    def validate(cls, table):
220        """Validate a leap-second table.
221
222        Parameters
223        ----------
224        table : array_like
225            Must have 'year', 'month', and 'tai_utc' entries.  If a 'day'
226            entry is present, it will be checked that it is always 1.
227            If ``table`` has an 'expires' attribute, it will be interpreted
228            as an expiration date.
229
230        Returns
231        -------
232        array : `~numpy.ndarray`
233            Structures array with 'year', 'month', 'tai_utc'.
234        expires: `~datetime.datetime` or None
235            Possible expiration date inferred from the table.  `None` if not
236            present or if not a `~datetime.datetime` or `~astropy.time.Time`
237            instance and not parsable as a 'dd month yyyy' string.
238
239        Raises
240        ------
241        ValueError
242            If the leap seconds in the table are not on the 1st of January or
243            July, or if the sorted TAI-UTC do not increase in increments of 1.
244        """
245        try:
246            day = table['day']
247        except Exception:
248            day = 1
249
250        expires = getattr(table, 'expires', None)
251        if expires is not None and not isinstance(expires, datetime):
252            # Maybe astropy Time? Cannot go via strftime, since that
253            # might need leap-seconds.  If not, try standard string
254            # format from leap_seconds.dat and leap_seconds.list
255            isot = getattr(expires, 'isot', None)
256            try:
257                if isot is not None:
258                    expires = datetime.strptime(isot.partition('T')[0],
259                                                '%Y-%m-%d')
260                else:
261                    expires = datetime.strptime(expires, '%d %B %Y')
262
263            except Exception as exc:
264                warn(f"ignoring non-datetime expiration {expires}; "
265                     f"parsing it raised {exc!r}", ErfaWarning)
266                expires = None
267
268        # Take care of astropy Table.
269        if hasattr(table, '__array__'):
270            table = table.__array__()[list(dt_eraLEAPSECOND.names)]
271
272        table = np.array(table, dtype=dt_eraLEAPSECOND, copy=False,
273                         ndmin=1)
274
275        # Simple sanity checks.
276        if table.ndim > 1:
277            raise ValueError("can only pass in one-dimensional tables.")
278
279        if not np.all(((day == 1) &
280                       (table['month'] == 1) | (table['month'] == 7)) |
281                      (table['year'] < 1972)):
282            raise ValueError("leap seconds inferred that are not on "
283                             "1st of January or 1st of July.")
284
285        if np.any((table['year'][:-1] > 1970) &
286                  (np.diff(table['tai_utc']) != 1)):
287            raise ValueError("jump in TAI-UTC by something else than one.")
288
289        return table, expires
290
291    @classmethod
292    def set(cls, table=None):
293        """Set the ERFA leap second table.
294
295        Note that it is generally safer to update the leap-second table than
296        to set it directly, since most tables do not have the pre-1970 changes
297        in TAI-UTC that are part of the built-in ERFA table.
298
299        Parameters
300        ----------
301        table : array_like or `None`
302            Leap-second table that should at least hold columns of 'year',
303            'month', and 'tai_utc'.  Only simple validation is done before it
304            is being used, so care need to be taken that entries are correct.
305            If `None`, reset the ERFA table to its built-in values.
306
307        Raises
308        ------
309        ValueError
310            If the leap seconds in the table are not on the 1st of January or
311            July, or if the sorted TAI-UTC do not increase in increments of 1.
312        """
313        if table is None:
314            expires = None
315        else:
316            table, expires = cls.validate(table)
317
318        set_leap_seconds(table)
319        cls._expires = expires
320
321    @classproperty
322    def expires(cls):
323        """The expiration date of the current ERFA table.
324
325        This is either a date inferred from the last table used to update or
326        set the leap-second array, or a number of days beyond the last leap
327        second.
328        """
329        if cls._expires is None:
330            last = cls.get()[-1]
331            return (datetime(last['year'], last['month'], 1) +
332                    timedelta(cls._expiration_days))
333        else:
334            return cls._expires
335
336    @classproperty
337    def expired(cls):
338        """Whether the leap second table is valid beyond the present."""
339        return cls.expires < datetime.now()
340
341    @classmethod
342    def update(cls, table):
343        """Add any leap seconds not already present to the ERFA table.
344
345        This method matches leap seconds with those present in the ERFA table,
346        and extends the latter as necessary.
347
348        If the ERFA leap seconds file was corrupted, it will be reset.
349
350        If the table is corrupted, the ERFA file will be unchanged.
351
352        Parameters
353        ----------
354        table : array_like or `~astropy.utils.iers.LeapSeconds`
355            Array or table with TAI-UTC from leap seconds.  Should have
356            'year', 'month', and 'tai_utc' columns.
357
358        Returns
359        -------
360        n_update : int
361            Number of items updated.
362
363        Raises
364        ------
365        ValueError
366            If the leap seconds in the table are not on the 1st of January or
367            July, or if the sorted TAI-UTC do not increase in increments of 1.
368        """
369        table, expires = cls.validate(table)
370
371        # Get erfa table and check it is OK; if not, reset it.
372        try:
373            erfa_ls, _ = cls.validate(cls.get())
374        except Exception:
375            cls.set()
376            erfa_ls = cls.get()
377
378        # Create the combined array and use it (validating the combination).
379        ls = np.union1d(erfa_ls, table)
380        cls.set(ls)
381
382        # If the update table has an expiration beyond that inferred from
383        # the new leap second second array, use it (but, now that the new
384        # array is set, do not allow exceptions due to misformed expires).
385        try:
386            if expires is not None and expires > cls.expires:
387                cls._expires = expires
388
389        except Exception as exc:
390            warn("table 'expires' attribute ignored as comparing it "
391                 "with a datetime raised an error:\n" + str(exc),
392                 ErfaWarning)
393
394        return len(ls) - len(erfa_ls)
395