1
2# -*- coding: utf-8 -*-
3
4u'''Im-/mutable, caching or memoizing properties and deprecation decorators.
5
6To enable C{DeprecationWarning}s from C{PyGeodesy}, set environment
7variable C{PYGEODESY_WARNINGS} to a non-empty string and run C{python}
8with command line option C{-X dev} or one or the C{-W} choices, see
9function L{DeprecationWarnings} below.
10'''
11from pygeodesy.errors import _AssertionError, _AttributeError, _xkwds
12from pygeodesy.interns import NN, _DOT_, _EQUALSPACED_, _immutable_, \
13                             _invalid_, MISSING, _N_A_, _SPACE_, _UNDER_
14from pygeodesy.lazily import _ALL_LAZY, _getenv, _FOR_DOCS, \
15                             _PYTHON_X_DEV, _sys
16from pygeodesy.streprs import Fmt
17
18from functools import wraps as _wraps
19from warnings import warn as _warn
20
21__all__ = _ALL_LAZY.props
22__version__ =  '21.09.14'
23
24_DEPRECATED_ = 'DEPRECATED'
25_dont_use_   = _DEPRECATED_ + ", don't use."
26_has_been_   = 'has been'
27_Warnings    =  0
28_W_DEV       = (bool(_sys.warnoptions) or _PYTHON_X_DEV) \
29                and _getenv('PYGEODESY_WARNINGS', NN)
30
31
32def _hasProperty(inst, name, *Classes):
33    '''(INTERNAL) Check whether C{inst} has a C{P/property/_RO} by this C{name}.
34    '''
35    ps = Classes if Classes else _PropertyBase
36    for c in inst.__class__.__mro__[:-1]:
37        p = c.__dict__.get(name, None)
38        if isinstance(p, ps) and p.name == name:
39            return True
40    return False
41
42
43def _update_all(inst, *attrs):
44    '''(INTERNAL) Zap all I{cached} L{property_RO}s, L{Property_RO}s
45       and the named C{attrs} from an instance' C{__dict__}.
46
47       @return: The number of updates (C{int}), if any.
48    '''
49    d = inst.__dict__
50    u = len(d)
51
52    for c in inst.__class__.__mro__[:-1]:
53        for n, p in c.__dict__.items():
54            if isinstance(p, _PropertyBase) and p.name == n:
55                p._update(inst, c)
56
57    p = d.pop
58    for a in attrs:  # PYCHOK no cover
59        if hasattr(inst, a):
60            p(a, None)
61        else:
62            from pygeodesy.named import classname
63            n =  classname(inst, prefixed=True)
64            a = _DOT_(n, _SPACE_(a, _invalid_))
65            raise _AssertionError(a, txt=repr(inst))
66
67    return u - len(d)  # of updates
68
69
70class _PropertyBase(property):
71    '''(INTERNAL) Base class for C{P/property/_RO}.
72    '''
73    def __init__(self, method, fget, fset, doc=NN):
74
75        if not callable(method):
76            self.getter(method)  # PYCHOK no cover
77
78        self.method = method
79        self.name   = method.__name__
80        d = doc or method.__doc__
81        if _FOR_DOCS and d:
82            self.__doc__ = d   # PYCHOK no cover
83
84        property.__init__(self, fget, fset, self._fdel, d or _N_A_)
85
86    def _fdel(self, inst):  # deleter
87        '''Zap the I{cached/memoized} C{property} value.
88        '''
89        self._update(inst, None)   # PYCHOK no cover
90
91    def _fget(self, inst):
92        '''Get and I{cache/memoize} the C{property} value.
93        '''
94        try:  # to get the value cached in instance' __dict__
95            return inst.__dict__[self.name]
96        except KeyError:
97            # cache the value in the instance' __dict__
98            inst.__dict__[self.name] = val = self.method(inst)
99            return val
100
101    def _fset_error(self, inst, val):
102        '''Throws an C{AttributeError}, always.
103        '''
104        from pygeodesy.named import classname
105        n = _DOT_(classname(inst), self.name)
106        self._immutable_error(_EQUALSPACED_(n, repr(val)))
107
108    def _immutable_error(self, name):
109        from pygeodesy.named import classname
110        e = _SPACE_(_immutable_, classname(self))
111        raise _AttributeError(e, txt=name)
112
113    def _invalid_error(self, name):
114        from pygeodesy.named import classname
115        e = _SPACE_(_invalid_, classname(self))
116        raise _AttributeError(e, txt=name)
117
118    def _update(self, inst, *unused):
119        '''(INTERNAL) Zap the I{cached/memoized} C{inst.__dict__[name]} item.
120        '''
121        inst.__dict__.pop(self.name, None)  # name, NOT _name
122
123    def deleter(self, fdel):
124        '''Throws an C{AttributeError}, always.
125        '''
126        n = _DOT_(self.name, self.deleter.__name__)
127        self._invalid_error(_SPACE_(n, fdel.__name__))
128
129    def getter(self, fget):
130        '''Throws an C{AttributeError}, always.
131        '''
132        n = _DOT_(self.name, self.getter.__name__)
133        self._invalid_error(_SPACE_(n, fget.__name__))
134
135    def setter(self, fset):
136        '''Throws an C{AttributeError}, always.
137        '''
138        n = _DOT_(self.name, self.setter.__name__)
139        self._immutable_error(_SPACE_(n, fset.__name__))
140
141
142class Property_RO(_PropertyBase):
143    # No __doc__ on purpose
144    def __init__(self, method, doc=NN):  # PYCHOK expected
145        '''New I{immutable}, I{caching}, I{memoizing} C{property} I{Factory}
146           to be used as C{decorator}.
147
148           @arg method: The callable being decorated as this C{property}'s C{getter},
149                        to be invoked only once.
150           @kwarg doc: Optional property documentation (C{str}).
151
152           @note: Like standard Python C{property} without a C{setter}, but with
153                  a more descriptive error message when set.
154
155           @see: Python 3's U{functools.cached_property<https://docs.Python.org/3/
156                 library/functools.html#functools.cached_property>} and U{-.cache
157                 <https://Docs.Python.org/3/library/functools.html#functools.cache>}
158                 to I{cache} or I{memoize} the property value.
159
160           @see: Luciano Ramalho, "Fluent Python", page 636, O'Reilly, 2016,
161                 "Coding a Property Factory", especially Example 19-24 and U{class
162                 Property<https://docs.Python.org/3/howto/descriptor.html>}.
163        '''
164        _fget = method if _FOR_DOCS else self._fget  # XXX force method.__doc__ to epydoc
165        _PropertyBase.__init__(self, method, _fget, self._fset_error)
166
167    def __get__(self, inst, *unused):  # PYCHOK 2 vs 3 args
168        if inst is None:
169            return self
170        try:  # to get the cached value immediately
171            return inst.__dict__[self.name]
172        except (AttributeError, KeyError):
173            return self._fget(inst)
174
175
176class Property(Property_RO):
177    # No __doc__ on purpose
178    __init__ = Property_RO.__init__
179    '''New I{mutable}, I{caching}, I{memoizing} C{property} I{Factory}
180       to be used as C{decorator}.
181
182       @see: L{Property_RO} for more details.
183
184       @note: Unless and until the C{setter} is defined, this L{Property} behaves
185              like an I{immutable}, I{caching}, I{memoizing} L{Property_RO}.
186    '''
187
188    def setter(self, method):
189        '''Make this C{Property} I{mutable}.
190
191           @arg method: The callable being decorated as this C{Property}'s C{setter}.
192
193           @note: Setting a new property value always clears the previously I{cached}
194                  or I{memoized} value I{after} invoking the B{C{method}}.
195        '''
196        if not callable(method):
197            _PropertyBase.setter(self, method)  # PYCHOK no cover
198
199        if _FOR_DOCS:  # XXX force method.__doc__ into epydoc
200            _PropertyBase.__init__(self, self.method, self.method, method)
201        else:
202
203            def _fset(inst, val):
204                '''Set and I{cache}, I{memoize} the C{property} value.
205                '''
206                method(inst, val)
207                self._update(inst)  # un-cache this item
208
209            # class Property <https://docs.Python.org/3/howto/descriptor.html>
210            _PropertyBase.__init__(self, self.method, self._fget, _fset)
211        return self
212
213
214class property_RO(_PropertyBase):
215    # No __doc__ on purpose
216    _uname = NN
217
218    def __init__(self, method, doc=NN):  # PYCHOK expected
219        '''New I{immutable}, standard C{property} to be used as C{decorator}.
220
221           @arg method: The callable being decorated as C{property}'s C{getter}.
222           @kwarg doc: Optional property documentation (C{str}).
223
224           @note: Like standard Python C{property} without a setter, but with
225                  a more descriptive error message when set.
226
227           @see: L{Property_RO}.
228        '''
229        _PropertyBase.__init__(self, method, method, self._fset_error, doc=doc)
230        self._uname = NN(_UNDER_, self.name)  # actual attr UNDER<name>
231
232    def _update(self, inst, *Clas):  # PYCHOK signature
233        '''(INTERNAL) Zap the I{cached} C{B{inst}.__dict__[_name]} item.
234        '''
235        uname = self._uname
236        if uname in inst.__dict__:
237            if Clas:  # overrides inst.__class__
238                d = Clas[0].__dict__.get(uname, MISSING)
239            else:
240                d = getattr(inst.__class__, uname, MISSING)
241#               if d is MISSING:  # XXX superfluous
242#                   for c in inst.__class__.__mro__[:-1]:
243#                       if uname in c.__dict__:
244#                           d = c.__dict__[uname]
245#                           break
246            if d is None:  # remove inst value
247                inst.__dict__.pop(uname)
248
249
250def property_doc_(doc):
251    '''Decorator for a standard C{property} with basic documentation.
252
253       @arg doc: The property documentation (C{str}).
254
255       @example:
256
257        >>> @property_doc_("documentation text.")
258        >>> def name(self):
259        >>>     ...
260        >>>
261        >>> @name.setter
262        >>> def name(self, value):
263        >>>     ...
264    '''
265    # See Luciano Ramalho, "Fluent Python", page 212ff, O'Reilly, 2016,
266    # "Parameterized Decorators", especially Example 7-23.  Also, see
267    # <https://Python-3-Patterns-Idioms-Test.ReadTheDocs.io/en/latest/PythonDecorators.html>
268
269    def _documented_property(method):
270        '''(INTERNAL) Return the documented C{property}.
271        '''
272        t = 'get and set' if doc.startswith(_SPACE_) else NN
273        return property(method, None, None, NN('Property to ', t, doc))
274
275    return _documented_property
276
277
278def _deprecated(call, kind, qual_d):
279    '''(INTERNAL) Decorator for DEPRECATED functions, methods, etc.
280
281       @see: Brett Slatkin, "Effective Python", page 105, 2nd ed,
282             Addison-Wesley, 2019.
283    '''
284    doc = _docof(call)
285
286    @_wraps(call)  # PYCHOK self?
287    def _deprecated_call(*args, **kwds):
288        if qual_d:  # function
289            q = qual_d
290        elif args:  # method
291            q = _qualified(args[0], call.__name__)
292        else:  # PYCHOK no cover
293            q = call.__name__
294        _throwarning(kind, q, doc)
295        return call(*args, **kwds)
296
297    return _deprecated_call
298
299
300def deprecated_class(cls_or_class):
301    '''Use inside __new__ or __init__ of a DEPRECATED class.
302
303       @arg cls_or_class: The class (C{cls} or C{Class}).
304
305       @note: NOT a decorator!
306    '''
307    if _W_DEV:
308        q = _DOT_(cls_or_class.__module__, cls_or_class.__name__)
309        _throwarning('class', q, cls_or_class.__doc__)
310
311
312def deprecated_function(call):
313    '''Decorator for a DEPRECATED function.
314
315       @arg call: The deprecated function (C{callable}).
316
317       @return: The B{C{call}} DEPRECATED.
318    '''
319    return _deprecated(call, 'function', _DOT_(
320                       call.__module__, call.__name__)) if \
321           _W_DEV else call
322
323
324def deprecated_method(call):
325    '''Decorator for a DEPRECATED method.
326
327       @arg call: The deprecated method (C{callable}).
328
329       @return: The B{C{call}} DEPRECATED.
330    '''
331    return _deprecated(call, 'method', NN) if _W_DEV else call
332
333
334def _deprecated_module(name):  # PYCHOK no cover
335    '''(INTERNAL) Callable within a DEPRECATED module.
336    '''
337    if _W_DEV:
338        _throwarning('module', name, _dont_use_)
339
340
341def deprecated_Property_RO(method):
342    '''Decorator for a DEPRECATED L{Property_RO}.
343
344       @arg method: The C{Property_RO.fget} method (C{callable}).
345
346       @return: The B{C{method}} DEPRECATED.
347    '''
348    return _deprecated_RO(method, Property_RO)
349
350
351def deprecated_property_RO(method):
352    '''Decorator for a DEPRECATED L{property_RO}.
353
354       @arg method: The C{property_RO.fget} method (C{callable}).
355
356       @return: The B{C{method}} DEPRECATED.
357    '''
358    return _deprecated_RO(method, property_RO)
359
360
361def _deprecated_RO(method, _RO):
362    '''(INTERNAL) Create a DEPRECATED C{property_RO} or C{Property_RO}.
363    '''
364    doc = _docof(method)
365
366    if _W_DEV:
367
368        class _Deprecated_RO(_PropertyBase):
369            __doc__ = doc
370
371            def __init__(self, method):
372                _PropertyBase.__init__(self, method, self._fget, self._fset_error, doc=doc)
373
374            def _fget(self, inst):  # PYCHOK no cover
375                q = _qualified(inst, self.name)
376                _throwarning(_RO.__name__, q, doc)
377                return self.method(inst)
378
379        return _Deprecated_RO(method)
380    else:  # PYCHOK no cover
381        return _RO(method, doc=doc)
382
383
384def DeprecationWarnings():
385    '''Get the C{DeprecationWarning}s reported or raised.
386
387       @return: The number of C{DeprecationWarning}s (C{int}) or
388                C{None} if not enabled.
389
390       @note: To get C{DeprecationWarning}s if any, run C{python}
391              with environment variable C{PYGEODESY_WARNINGS} set
392              to a non-empty string AND use C{python} command line
393              option C{-X dev}, C{-W always}, or C{-W error}, etc.
394    '''
395    return _Warnings if _W_DEV else None
396
397
398def _docof(obj):
399    '''(INTERNAL) Get uniform DEPRECATED __doc__ string.
400    '''
401    try:
402        d = obj.__doc__.strip()
403        i = d.find(_DEPRECATED_)
404    except AttributeError:
405        i = -1
406    return _DOT_(_DEPRECATED_, NN) if i < 0 else d[i:]
407
408
409def _qualified(inst, name):
410    '''(INTERNAL) Fully qualify a name.
411    '''
412    # _DOT_(inst.classname, name), not _DOT_(inst.named4, name)
413    c =  inst.__class__
414    q = _DOT_(c.__module__, c.__name__, name)
415    return q
416
417
418def _throwarning(kind, name, doc, **stacklevel):  # stacklevel=3
419    '''(INTERNAL) Report or raise a C{DeprecationWarning}.
420    '''
421    line =  doc.split('\n\n', 1)[0].split()
422    text = _SPACE_(kind, Fmt.CURLY(L=name), _has_been_, *line)
423    kwds = _xkwds(stacklevel, stacklevel=3)
424    # XXX _warn or raise DeprecationWarning(text)
425    _warn(text, category=DeprecationWarning, **kwds)
426
427    global _Warnings
428    _Warnings += 1
429
430# **) MIT License
431#
432# Copyright (C) 2016-2021 -- mrJean1 at Gmail -- All Rights Reserved.
433#
434# Permission is hereby granted, free of charge, to any person obtaining a
435# copy of this software and associated documentation files (the "Software"),
436# to deal in the Software without restriction, including without limitation
437# the rights to use, copy, modify, merge, publish, distribute, sublicense,
438# and/or sell copies of the Software, and to permit persons to whom the
439# Software is furnished to do so, subject to the following conditions:
440#
441# The above copyright notice and this permission notice shall be included
442# in all copies or substantial portions of the Software.
443#
444# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
445# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
446# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
447# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
448# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
449# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
450# OTHER DEALINGS IN THE SOFTWARE.
451