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