Coverage for pygeodesy/props.py: 98%

202 statements  

« prev     ^ index     » next       coverage.py v7.2.2, created at 2023-04-21 13:14 -0400

1 

2# -*- coding: utf-8 -*- 

3 

4u'''Mutable, immutable and caching/memoizing properties and 

5deprecation decorators. 

6 

7To enable C{DeprecationWarning}s from C{PyGeodesy}, set env var 

8C{PYGEODESY_WARNINGS} to a non-empty string I{AND} run C{python} 

9with command line option C{-X dev} or with one of the C{-W} 

10choices, see callable L{DeprecationWarnings} below. 

11''' 

12 

13from pygeodesy.basics import isclass 

14from pygeodesy.errors import _AssertionError, _AttributeError, \ 

15 _xkwds, _xkwds_get 

16from pygeodesy.interns import MISSING, NN, _an_, _COMMASPACE_, \ 

17 _DEPRECATED_, _DOT_, _EQUALSPACED_, \ 

18 _immutable_, _invalid_, _N_A_, _not_, \ 

19 _SPACE_, _UNDER_, _DNL_ # PYCHOK used! 

20# from pygeodesy.named import callname # from _MODS, avoid circular 

21from pygeodesy.lazily import _ALL_LAZY, _ALL_MODS as _MODS, \ 

22 _FOR_DOCS, _WARNINGS_X_DEV 

23# from pygeodesy.streprs import Fmt # from _MODS 

24 

25from functools import wraps as _wraps 

26 

27__all__ = _ALL_LAZY.props 

28__version__ = '22.09.23' 

29 

30_class_ = 'class' 

31_dont_use_ = _DEPRECATED_ + ", don't use." 

32_function_ = 'function' 

33_get_and_set_ = 'get and set' 

34_has_been_ = 'has been' # PYCHOK used! 

35_method_ = 'method' 

36_not_an_inst_ = _not_(_an_, 'instance') 

37 

38 

39def _allPropertiesOf(Clas_or_inst, *Bases): 

40 '''(INTERNAL) Yield all C{R/property/_RO}s at C{Clas_or_inst} 

41 as specified in the C{Bases} arguments. 

42 ''' 

43 if isclass(Clas_or_inst): 

44 S = Clas_or_inst, # just this Clas 

45 else: # class and super-classes of inst 

46 try: 

47 S = Clas_or_inst.__class__.__mro__[:-1] # not object 

48 except AttributeError: 

49 raise 

50 S = () # not an inst 

51 B = Bases or _PropertyBase 

52 for C in S: 

53 for n, p in C.__dict__.items(): 

54 if isinstance(p, B) and p.name == n: 

55 yield p 

56 

57 

58def _allPropertiesOf_n(n, Clas_or_inst, *Bases): 

59 '''(INTERNAL) Assert the number of C{R/property/_RO}s at C{Clas_or_inst}. 

60 ''' 

61 t = tuple(p.name for p in _allPropertiesOf(Clas_or_inst, *Bases)) 

62 if len(t) != n: 

63 raise _AssertionError(_COMMASPACE_.join(t), Clas_or_inst, 

64 txt=_COMMASPACE_(len(t), _not_(n))) 

65 return t 

66 

67 

68def _hasProperty(inst, name, *Classes): # in .named._NamedBase._update 

69 '''(INTERNAL) Check whether C{inst} has a C{P/property/_RO} by this C{name}. 

70 ''' 

71 p = getattr(inst.__class__, name, None) # walks __class__.__mro__ 

72 return bool(p and isinstance(p, Classes or _PropertyBase) 

73 and p.name == name) 

74 

75 

76def _update_all(inst, *attrs, **Base): 

77 '''(INTERNAL) Zap all I{cached} L{property_RO}s, L{Property}s, 

78 L{Property_RO}s and the named C{attrs} of an instance. 

79 

80 @return: The number of updates (C{int}), if any. 

81 ''' 

82 if isclass(inst): 

83 raise _AssertionError(inst, txt=_not_an_inst_) 

84 try: 

85 d = inst.__dict__ 

86 except AttributeError: 

87 return 0 

88 u = len(d) 

89 if u: 

90 B = _xkwds_get(Base, Base=_PropertyBase) 

91 for p in _allPropertiesOf(inst, B): 

92 p._update(inst) # d.pop(p.name, None) 

93 

94 if attrs: 

95 _update_attrs(inst, *attrs) # remove attributes from inst.__dict__ 

96 u -= len(d) 

97 return u # updates 

98 

99 

100# def _update_all_from(inst, other, **Base): 

101# '''(INTERNAL) Update all I{cached} L{Property}s and 

102# L{Property_RO}s of instance C{inst} from C{other}. 

103# 

104# @return: The number of updates (C{int}), if any. 

105# ''' 

106# if isclass(inst): 

107# raise _AssertionError(inst, txt=_not_an_inst_) 

108# try: 

109# d = inst.__dict__ 

110# f = other.__dict__ 

111# except AttributeError: 

112# return 0 

113# u = len(f) 

114# if u: 

115# u = len(d) 

116# B = _xkwds_get(Base, Base=_PropertyBase) 

117# for p in _allPropertiesOf(inst, B): 

118# p._update_from(inst, other) 

119# u -= len(d) 

120# return u # updates 

121 

122 

123def _update_attrs(inst, *attrs): 

124 '''(INTERNAL) Zap all named C{attrs} of an instance. 

125 

126 @return: The number of updates (C{int}), if any. 

127 ''' 

128 try: 

129 d = inst.__dict__ 

130 except AttributeError: 

131 return 0 

132 u = len(d) 

133 if u: 

134 _p = d.pop # zap attrs from inst.__dict__ 

135 for a in attrs: # PYCHOK no cover 

136 if _p(a, MISSING) is MISSING and not hasattr(inst, a): 

137 n = _MODS.named.classname(inst, prefixed=True) 

138 a = _DOT_(n, _SPACE_(a, _invalid_)) 

139 raise _AssertionError(a, txt=repr(inst)) 

140 u -= len(d) 

141 return u # updates 

142 

143 

144class _PropertyBase(property): 

145 '''(INTERNAL) Base class for C{P/property/_RO}. 

146 ''' 

147 def __init__(self, method, fget, fset, doc=NN): 

148 

149 if not callable(method): 

150 self.getter(method) # PYCHOK no cover 

151 

152 self.method = method 

153 self.name = method.__name__ 

154 d = doc or method.__doc__ 

155 if _FOR_DOCS and d: 

156 self.__doc__ = d # PYCHOK no cover 

157 

158 property.__init__(self, fget, fset, self._fdel, d or _N_A_) 

159 

160 def _fdel(self, inst): 

161 '''Zap the I{cached/memoized} C{property} value. 

162 ''' 

163 self._update(inst, None) # PYCHOK no cover 

164 

165 def _fget(self, inst): 

166 '''Get and I{cache/memoize} the C{property} value. 

167 ''' 

168 try: # to get the value cached in instance' __dict__ 

169 return inst.__dict__[self.name] 

170 except KeyError: 

171 # cache the value in the instance' __dict__ 

172 inst.__dict__[self.name] = val = self.method(inst) 

173 return val 

174 

175 def _fset_error(self, inst, val): 

176 '''Throws an C{AttributeError}, always. 

177 ''' 

178 n = _MODS.named.classname(inst) 

179 n = _DOT_(n, self.name) 

180 n = _EQUALSPACED_(n, repr(val)) 

181 raise self._Error(_immutable_, n, None) 

182 

183 def _update(self, inst, *unused): 

184 '''(INTERNAL) Zap the I{cached/memoized} C{inst.__dict__[name]} item. 

185 ''' 

186 inst.__dict__.pop(self.name, None) # name, NOT _name 

187 

188 def _update_from(self, inst, other): 

189 '''(INTERNAL) Copy a I{cached/memoized} C{inst.__dict__[name]} item 

190 from C{other.__dict__[name]} if present, otherwise zap it. 

191 ''' 

192 n = self.name # name, NOT _name 

193 v = other.__dict__.get(n, MISSING) 

194 if v is MISSING: 

195 inst.__dict__.pop(n, None) 

196 else: 

197 inst.__dict__[n] = v 

198 

199 def deleter(self, fdel): 

200 '''Throws an C{AttributeError}, always. 

201 ''' 

202 raise self._Error(_invalid_, self.deleter, fdel) 

203 

204 def getter(self, fget): 

205 '''Throws an C{AttributeError}, always. 

206 ''' 

207 raise self._Error(_invalid_, self.getter, fget) 

208 

209 def setter(self, fset): 

210 '''Throws an C{AttributeError}, always. 

211 ''' 

212 raise self._Error(_immutable_, self.setter, fset) 

213 

214 def _Error(self, kind, nameter, farg): 

215 '''(INTERNAL) Return an C{AttributeError} instance. 

216 ''' 

217 if farg: 

218 n = _DOT_(self.name, nameter.__name__) 

219 n = _SPACE_(n, farg.__name__) 

220 else: 

221 n = nameter 

222 e = _SPACE_(kind, _MODS.named.classname(self)) 

223 return _AttributeError(e, txt=n) 

224 

225 

226class Property_RO(_PropertyBase): 

227 # No __doc__ on purpose 

228 def __init__(self, method, doc=NN): # PYCHOK expected 

229 '''New I{immutable}, I{caching}, I{memoizing} C{property} I{Factory} 

230 to be used as C{decorator}. 

231 

232 @arg method: The callable being decorated as this C{property}'s C{getter}, 

233 to be invoked only once. 

234 @kwarg doc: Optional property documentation (C{str}). 

235 

236 @note: Like standard Python C{property} without a C{setter}, but with 

237 a more descriptive error message when set. 

238 

239 @see: Python 3's U{functools.cached_property<https://docs.Python.org/3/ 

240 library/functools.html#functools.cached_property>} and U{-.cache 

241 <https://Docs.Python.org/3/library/functools.html#functools.cache>} 

242 to I{cache} or I{memoize} the property value. 

243 

244 @see: Luciano Ramalho, "Fluent Python", page 636, O'Reilly, 2016, 

245 "Coding a Property Factory", especially Example 19-24 and U{class 

246 Property<https://docs.Python.org/3/howto/descriptor.html>}. 

247 ''' 

248 _fget = method if _FOR_DOCS else self._fget # XXX force method.__doc__ to epydoc 

249 _PropertyBase.__init__(self, method, _fget, self._fset_error) 

250 

251 def __get__(self, inst, *unused): # PYCHOK 2 vs 3 args 

252 if inst is None: 

253 return self 

254 try: # to get the cached value immediately 

255 return inst.__dict__[self.name] 

256 except (AttributeError, KeyError): 

257 return self._fget(inst) 

258 

259 

260class Property(Property_RO): 

261 # No __doc__ on purpose 

262 __init__ = Property_RO.__init__ 

263 '''New I{mutable}, I{caching}, I{memoizing} C{property} I{Factory} 

264 to be used as C{decorator}. 

265 

266 @see: L{Property_RO} for more details. 

267 

268 @note: Unless and until the C{setter} is defined, this L{Property} behaves 

269 like an I{immutable}, I{caching}, I{memoizing} L{Property_RO}. 

270 ''' 

271 

272 def setter(self, method): 

273 '''Make this C{Property} I{mutable}. 

274 

275 @arg method: The callable being decorated as this C{Property}'s C{setter}. 

276 

277 @note: Setting a new property value always clears the previously I{cached} 

278 or I{memoized} value I{after} invoking the B{C{method}}. 

279 ''' 

280 if not callable(method): 

281 _PropertyBase.setter(self, method) # PYCHOK no cover 

282 

283 if _FOR_DOCS: # XXX force method.__doc__ into epydoc 

284 _PropertyBase.__init__(self, self.method, self.method, method) 

285 else: 

286 

287 def _fset(inst, val): 

288 '''Set and I{cache}, I{memoize} the C{property} value. 

289 ''' 

290 method(inst, val) 

291 self._update(inst) # un-cache this item 

292 

293 # class Property <https://docs.Python.org/3/howto/descriptor.html> 

294 _PropertyBase.__init__(self, self.method, self._fget, _fset) 

295 return self 

296 

297 

298class property_RO(_PropertyBase): 

299 # No __doc__ on purpose 

300 _uname = NN 

301 

302 def __init__(self, method, doc=NN): # PYCHOK expected 

303 '''New I{immutable}, standard C{property} to be used as C{decorator}. 

304 

305 @arg method: The callable being decorated as C{property}'s C{getter}. 

306 @kwarg doc: Optional property documentation (C{str}). 

307 

308 @note: Like standard Python C{property} without a setter, but with 

309 a more descriptive error message when set. 

310 

311 @see: L{Property_RO}. 

312 ''' 

313 _PropertyBase.__init__(self, method, method, self._fset_error, doc=doc) 

314 self._uname = NN(_UNDER_, self.name) # actual attr UNDER<name> 

315 

316 def _update(self, inst, *Clas): # PYCHOK signature 

317 '''(INTERNAL) Zap the I{cached} C{B{inst}.__dict__[_name]} item. 

318 ''' 

319 uname = self._uname 

320 if uname in inst.__dict__: 

321 if Clas: # overrides inst.__class__ 

322 d = Clas[0].__dict__.get(uname, MISSING) 

323 else: 

324 d = getattr(inst.__class__, uname, MISSING) 

325# if d is MISSING: # XXX superfluous 

326# for c in inst.__class__.__mro__[:-1]: 

327# if uname in c.__dict__: 

328# d = c.__dict__[uname] 

329# break 

330 if d is None: # remove inst value 

331 inst.__dict__.pop(uname) 

332 

333 

334class _NamedProperty(property): 

335 '''Class C{property} with retrievable name. 

336 ''' 

337 @Property_RO 

338 def name(self): 

339 '''Get the name of this C{property} (C{str}). 

340 ''' 

341 return self.fget.__name__ 

342 

343 

344def property_doc_(doc): 

345 '''Decorator for a standard C{property} with basic documentation. 

346 

347 @arg doc: The property documentation (C{str}). 

348 

349 @example: 

350 

351 >>> @property_doc_("documentation text.") 

352 >>> def name(self): 

353 >>> ... 

354 >>> 

355 >>> @name.setter 

356 >>> def name(self, value): 

357 >>> ... 

358 ''' 

359 # See Luciano Ramalho, "Fluent Python", page 212ff, O'Reilly, 2016, 

360 # "Parameterized Decorators", especially Example 7-23. Also, see 

361 # <https://Python-3-Patterns-Idioms-Test.ReadTheDocs.io/en/latest/PythonDecorators.html> 

362 

363 def _documented_property(method): 

364 '''(INTERNAL) Return the documented C{property}. 

365 ''' 

366 t = _get_and_set_ if doc.startswith(_SPACE_) else NN 

367 return _NamedProperty(method, None, None, NN('Property to ', t, doc)) 

368 

369 return _documented_property 

370 

371 

372def _deprecated(call, kind, qual_d): 

373 '''(INTERNAL) Decorator for DEPRECATED functions, methods, etc. 

374 

375 @see: Brett Slatkin, "Effective Python", page 105, 2nd ed, 

376 Addison-Wesley, 2019. 

377 ''' 

378 doc = _docof(call) 

379 

380 @_wraps(call) # PYCHOK self? 

381 def _deprecated_call(*args, **kwds): 

382 if qual_d: # function 

383 q = qual_d 

384 elif args: # method 

385 q = _qualified(args[0], call.__name__) 

386 else: # PYCHOK no cover 

387 q = call.__name__ 

388 _throwarning(kind, q, doc) 

389 return call(*args, **kwds) 

390 

391 return _deprecated_call 

392 

393 

394def deprecated_class(cls_or_class): 

395 '''Use inside __new__ or __init__ of a DEPRECATED class. 

396 

397 @arg cls_or_class: The class (C{cls} or C{Class}). 

398 

399 @note: NOT a decorator! 

400 ''' 

401 if _WARNINGS_X_DEV: 

402 q = _DOT_(cls_or_class.__module__, cls_or_class.__name__) 

403 _throwarning(_class_, q, cls_or_class.__doc__) 

404 

405 

406def deprecated_function(call): 

407 '''Decorator for a DEPRECATED function. 

408 

409 @arg call: The deprecated function (C{callable}). 

410 

411 @return: The B{C{call}} DEPRECATED. 

412 ''' 

413 return _deprecated(call, _function_, _DOT_( 

414 call.__module__, call.__name__)) if \ 

415 _WARNINGS_X_DEV else call 

416 

417 

418def deprecated_method(call): 

419 '''Decorator for a DEPRECATED method. 

420 

421 @arg call: The deprecated method (C{callable}). 

422 

423 @return: The B{C{call}} DEPRECATED. 

424 ''' 

425 return _deprecated(call, _method_, NN) if _WARNINGS_X_DEV else call 

426 

427 

428def _deprecated_module(name): # PYCHOK no cover 

429 '''(INTERNAL) Callable within a DEPRECATED module. 

430 ''' 

431 if _WARNINGS_X_DEV: 

432 _throwarning('module', name, _dont_use_) 

433 

434 

435if _WARNINGS_X_DEV: 

436 class deprecated_property(_PropertyBase): 

437 '''Decorator for a DEPRECATED C{property} or C{Property}. 

438 ''' 

439 def __init__(self, method): 

440 '''Decorator for a DEPRECATED C{property} or C{Property} getter. 

441 ''' 

442 doc = _docof(method) 

443 

444 def _fget(inst): # PYCHOK no cover 

445 '''Get the C{property} or C{Property} value. 

446 ''' 

447 q = _qualified(inst, self.name) 

448 _throwarning(property.__name__, q, doc) 

449 return self.method(inst) # == method 

450 

451 _PropertyBase.__init__(self, method, _fget, None, doc=doc) 

452 

453 def setter(self, method): 

454 '''Decorator for a DEPRECATED C{property} or C{Property} setter. 

455 

456 @arg method: The callable being decorated as this C{Property}'s C{setter}. 

457 

458 @note: Setting a new property value always clears the previously I{cached} 

459 or I{memoized} value I{after} invoking the B{C{method}}. 

460 ''' 

461 if not callable(method): 

462 _PropertyBase.setter(self, method) # PYCHOK no cover 

463 

464 if _FOR_DOCS: # XXX force method.__doc__ into epydoc 

465 _PropertyBase.__init__(self, self.method, self.method, method) 

466 else: 

467 

468 def _fset(inst, val): 

469 '''Set the C{property} or C{Property} value. 

470 ''' 

471 q = _qualified(inst, self.name) 

472 _throwarning(property.__name__, q, _docof(method)) 

473 method(inst, val) 

474 # self._update(inst) # un-cache this item 

475 

476 # class Property <https://docs.Python.org/3/howto/descriptor.html> 

477 _PropertyBase.__init__(self, self.method, self._fget, _fset) 

478 return self 

479 

480else: # PYCHOK no cover 

481 class deprecated_property(property): # PYCHOK expected 

482 '''Decorator for a DEPRECATED C{property} or C{Property}. 

483 ''' 

484 pass 

485 

486deprecated_Property = deprecated_property 

487 

488 

489def deprecated_Property_RO(method): 

490 '''Decorator for a DEPRECATED L{Property_RO}. 

491 

492 @arg method: The C{Property_RO.fget} method (C{callable}). 

493 

494 @return: The B{C{method}} DEPRECATED. 

495 ''' 

496 return _deprecated_RO(method, Property_RO) 

497 

498 

499def deprecated_property_RO(method): 

500 '''Decorator for a DEPRECATED L{property_RO}. 

501 

502 @arg method: The C{property_RO.fget} method (C{callable}). 

503 

504 @return: The B{C{method}} DEPRECATED. 

505 ''' 

506 return _deprecated_RO(method, property_RO) 

507 

508 

509def _deprecated_RO(method, _RO): 

510 '''(INTERNAL) Create a DEPRECATED C{property_RO} or C{Property_RO}. 

511 ''' 

512 doc = _docof(method) 

513 

514 if _WARNINGS_X_DEV: 

515 

516 class _Deprecated_RO(_PropertyBase): 

517 __doc__ = doc 

518 

519 def __init__(self, method): 

520 _PropertyBase.__init__(self, method, self._fget, self._fset_error, doc=doc) 

521 

522 def _fget(self, inst): # PYCHOK no cover 

523 q = _qualified(inst, self.name) 

524 _throwarning(_RO.__name__, q, doc) 

525 return self.method(inst) 

526 

527 return _Deprecated_RO(method) 

528 else: # PYCHOK no cover 

529 return _RO(method, doc=doc) 

530 

531 

532def _docof(obj): 

533 '''(INTERNAL) Get uniform DEPRECATED __doc__ string. 

534 ''' 

535 try: 

536 d = obj.__doc__.strip() 

537 i = d.find(_DEPRECATED_) 

538 except AttributeError: 

539 i = -1 

540 return _DOT_(_DEPRECATED_, NN) if i < 0 else d[i:] 

541 

542 

543def _qualified(inst, name): 

544 '''(INTERNAL) Fully qualify a name. 

545 ''' 

546 # _DOT_(inst.classname, name), not _DOT_(inst.named4, name) 

547 c = inst.__class__ 

548 q = _DOT_(c.__module__, c.__name__, name) 

549 return q 

550 

551 

552class DeprecationWarnings(object): 

553 '''(INTERNAL) Handle C{DeprecationWaring}s. 

554 ''' 

555 _Warnings = 0 

556 

557 def __call__(self): # for backward compatibility 

558 '''Have any C{DeprecationWarning}s been reported or raised? 

559 

560 @return: The number of C{DeprecationWarning}s (C{int}) so 

561 far or C{None} if not enabled. 

562 

563 @note: To get C{DeprecationWarning}s if any, run C{python} 

564 with env var C{PYGEODESY_WARNINGS} set to a non-empty 

565 string I{AND} use C{python[3]} command line option 

566 C{-X dev}, C{-W always} or C{-W error}, etc. 

567 ''' 

568 return self.Warnings 

569 

570 def throw(self, kind, name, doc, **stacklevel): # stacklevel=3 

571 '''Report or raise a C{DeprecationWarning}. 

572 ''' 

573 line = doc.split(_DNL_, 1)[0].strip() 

574 name = _MODS.streprs.Fmt.CURLY(L=name) 

575 text = _SPACE_(kind, name, _has_been_, *line.split()) 

576 kwds = _xkwds(stacklevel, stacklevel=3) 

577 # XXX invoke warn or raise DeprecationWarning(text) 

578 self._warn(text, category=DeprecationWarning, **kwds) 

579 self._Warnings += 1 

580 

581 @Property_RO 

582 def _warn(self): 

583 '''Get Python's C{warnings.warn}. 

584 ''' 

585 from warnings import warn 

586 return warn 

587 

588 @property_RO 

589 def Warnings(self): 

590 '''Get the number of C{DeprecationWarning}s (C{int}) so 

591 far or C{None} if not enabled. 

592 ''' 

593 return self._Warnings if _WARNINGS_X_DEV else None 

594 

595DeprecationWarnings = DeprecationWarnings() # PYCHOK singleton 

596_throwarning = DeprecationWarnings.throw 

597 

598# **) MIT License 

599# 

600# Copyright (C) 2016-2023 -- mrJean1 at Gmail -- All Rights Reserved. 

601# 

602# Permission is hereby granted, free of charge, to any person obtaining a 

603# copy of this software and associated documentation files (the "Software"), 

604# to deal in the Software without restriction, including without limitation 

605# the rights to use, copy, modify, merge, publish, distribute, sublicense, 

606# and/or sell copies of the Software, and to permit persons to whom the 

607# Software is furnished to do so, subject to the following conditions: 

608# 

609# The above copyright notice and this permission notice shall be included 

610# in all copies or substantial portions of the Software. 

611# 

612# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 

613# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 

614# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 

615# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 

616# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 

617# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 

618# OTHER DEALINGS IN THE SOFTWARE.