├── .coveragerc ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── api.rst ├── changelog.rst ├── conf.py ├── index.rst └── readme.rst ├── property_manager ├── __init__.py ├── sphinx.py └── tests.py ├── requirements-checks.txt ├── requirements-tests.txt ├── requirements-travis.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc: Configuration file for coverage.py. 2 | # http://nedbatchelder.com/code/coverage/ 3 | 4 | [run] 5 | source = property_manager 6 | omit = property_manager/tests.py 7 | 8 | # vim: ft=dosini 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.5" 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | - "pypy" 9 | install: 10 | - pip install --requirement=requirements-travis.txt 11 | - LC_ALL=C pip install . 12 | script: 13 | - make check 14 | - make test 15 | after_success: 16 | - coveralls 17 | branches: 18 | except: 19 | - /^[0-9]/ 20 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | The purpose of this document is to list all of the notable changes to this 5 | project. The format was inspired by `Keep a Changelog`_. This project adheres 6 | to `semantic versioning`_. 7 | 8 | .. contents:: 9 | :local: 10 | 11 | .. _Keep a Changelog: http://keepachangelog.com/ 12 | .. _semantic versioning: http://semver.org/ 13 | 14 | `Release 3.0`_ (2020-03-02) 15 | --------------------------- 16 | 17 | No exciting changes, mostly just project maintenance 😇. 18 | 19 | - Merge pull request `#2`_: Fix deprecation warnings caused by importing 20 | :class:`python2:collections.Hashable` on Python 3.3+ (fixes issue `#1`_). 21 | 22 | - Drop support for Python 2.6 and 3.4, start testing on 3.7 and 3.8. 23 | 24 | - Change order of hints & overview in generated documentation. 25 | 26 | - Updated to :pypi:`humanfriendly` 8.0 (to fix deprecated imports). 27 | 28 | - Updated the ``Makefile`` to use Python 3 for local development. 29 | 30 | - Switched the coveralls badge in the readme to SVG. 31 | 32 | - Changed the Read the Docs base URL. 33 | 34 | .. _Release 3.0: https://github.com/xolox/python-property-manager/compare/2.3.1...3.0 35 | .. _#1: https://github.com/xolox/python-property-manager/issues/1 36 | .. _#2: https://github.com/xolox/python-property-manager/pull/2 37 | 38 | `Release 2.3.1`_ (2018-05-19) 39 | ----------------------------- 40 | 41 | Minor bug fix release to sort the property names in the overview appended to 42 | class docstrings (I'm not sure what the implicit order was but it definitely 43 | wasn't alphabetical :-p). 44 | 45 | .. _Release 2.3.1: https://github.com/xolox/python-property-manager/compare/2.3...2.3.1 46 | 47 | `Release 2.3`_ (2018-04-27) 48 | --------------------------- 49 | 50 | - Added ``property_manager.sphinx`` module to automatically generate boilerplate documentation. 51 | - Added ``license`` and removed ``test_suite`` key in ``setup.py`` script. 52 | - Include documentation in source distributions. 53 | - Change Sphinx documentation theme. 54 | - Added this changelog. 55 | 56 | .. _Release 2.3: https://github.com/xolox/python-property-manager/compare/2.2...2.3 57 | 58 | `Release 2.2`_ (2017-06-29) 59 | --------------------------- 60 | 61 | - Decomposed ``__repr__()`` into property selection and rendering functionality. 62 | - Added Python 3.6 to tested and supported versions. 63 | - Properly documented logging configuration. 64 | - Switched Sphinx theme (default → classic). 65 | - Refactored ``setup.py`` script and ``Makefile``: 66 | 67 | - Added wheel distributions (``setup.cfg``). 68 | - Fixed code style checks. 69 | 70 | .. _Release 2.2: https://github.com/xolox/python-property-manager/compare/2.1...2.2 71 | 72 | `Release 2.1`_ (2016-06-15) 73 | --------------------------- 74 | 75 | Remove fancy but superfluous words from ``DYNAMIC_PROPERTY_NOTE`` :-). 76 | 77 | .. _Release 2.1: https://github.com/xolox/python-property-manager/compare/2.0...2.1 78 | 79 | `Release 2.0`_ (2016-06-15) 80 | --------------------------- 81 | 82 | Easy to use ``PropertyManager`` object hashing and comparisons. 83 | 84 | .. _Release 2.0: https://github.com/xolox/python-property-manager/compare/1.6...2.0 85 | 86 | `Release 1.6`_ (2016-06-01) 87 | --------------------------- 88 | 89 | Support for setters, deleters and logging. 90 | 91 | .. _Release 1.6: https://github.com/xolox/python-property-manager/compare/1.5...1.6 92 | 93 | `Release 1.5`_ (2016-06-01) 94 | --------------------------- 95 | 96 | - Added ``set_property()`` and ``clear_property()`` functions. 97 | - Added Python 3.5 to tested and supported versions. 98 | - Rearranged class variables and their documentation (I'm still getting up to 99 | speed with Sphinx, have been doing so for years, probably I'll still be 100 | learning new things a few years from now :-). 101 | 102 | .. _Release 1.5: https://github.com/xolox/python-property-manager/compare/1.4...1.5 103 | 104 | `Release 1.4`_ (2016-05-31) 105 | --------------------------- 106 | 107 | - Only inject usage notes when applicable. 108 | - Start using the ``humanfriendly.sphinx`` module. 109 | 110 | .. _Release 1.4: https://github.com/xolox/python-property-manager/compare/1.3...1.4 111 | 112 | `Release 1.3`_ (2015-11-25) 113 | --------------------------- 114 | 115 | Support for properties whose values are based on environment variables. 116 | 117 | .. _Release 1.3: https://github.com/xolox/python-property-manager/compare/1.2...1.3 118 | 119 | `Release 1.2`_ (2015-10-06) 120 | --------------------------- 121 | 122 | Made it possible to opt out of usage notes. 123 | 124 | .. _Release 1.2: https://github.com/xolox/python-property-manager/compare/1.1.1...1.2 125 | 126 | `Release 1.1.1`_ (2015-10-04) 127 | ----------------------------- 128 | 129 | - Made ``repr()`` render only properties of subclasses. 130 | - Removed indentation from doctest formatted code samples in readme. 131 | 132 | .. _Release 1.1.1: https://github.com/xolox/python-property-manager/compare/1.1...1.1.1 133 | 134 | `Release 1.1`_ (2015-10-04) 135 | --------------------------- 136 | 137 | - Documented similar projects and distinguishing features. 138 | - Improved the structure of the documentation. 139 | 140 | .. _Release 1.1: https://github.com/xolox/python-property-manager/compare/1.0.1...1.1 141 | 142 | `Release 1.0.1`_ (2015-10-04) 143 | ----------------------------- 144 | 145 | - Improved usage notes of dynamically constructed subclasses. 146 | - Added PyPI trove classifiers to ``setup.py`` script. 147 | - Added Travis CI configuration. 148 | 149 | .. _Release 1.0.1: https://github.com/xolox/python-property-manager/compare/1.0...1.0.1 150 | 151 | `Release 1.0`_ (2015-10-04) 152 | --------------------------- 153 | 154 | The initial commit and release. Relevant notes from the readme: 155 | 156 | The `property-manager` package came into existence as a submodule of my 157 | executor_ package where I wanted to define classes with a lot of properties 158 | that had a default value which was computed on demand but also needed to 159 | support assignment to easily override the default value. 160 | 161 | Since I created that module I'd wanted to re-use it in a couple of other 162 | projects I was working on, but adding an `executor` dependency just for the 163 | `property_manager` submodule felt kind of ugly. 164 | 165 | This is when I decided that it was time for the `property-manager` package to 166 | be created. When I extracted the submodule from `executor` I significantly 167 | changed its implementation (making the code more robust and flexible) and 168 | improved the tests, documentation and coverage in the process. 169 | 170 | .. _Release 1.0: https://github.com/xolox/python-property-manager/tree/1.0 171 | .. _executor: https://executor.readthedocs.io/en/latest/ 172 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Peter Odding 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | graft docs 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for the `property-manager' package. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 2, 2020 5 | # URL: https://github.com/xolox/python-property-manager 6 | 7 | PACKAGE_NAME = property-manager 8 | WORKON_HOME ?= $(HOME)/.virtualenvs 9 | VIRTUAL_ENV ?= $(WORKON_HOME)/$(PACKAGE_NAME) 10 | PYTHON ?= python3 11 | PATH := $(VIRTUAL_ENV)/bin:$(PATH) 12 | MAKE := $(MAKE) --no-print-directory 13 | SHELL = bash 14 | 15 | default: 16 | @echo "Makefile for $(PACKAGE_NAME)" 17 | @echo 18 | @echo 'Usage:' 19 | @echo 20 | @echo ' make install install the package in a virtual environment' 21 | @echo ' make reset recreate the virtual environment' 22 | @echo ' make check check coding style (PEP-8, PEP-257)' 23 | @echo ' make test run the test suite, report coverage' 24 | @echo ' make tox run the tests on all Python versions' 25 | @echo ' make docs update documentation using Sphinx' 26 | @echo ' make publish publish changes to GitHub/PyPI' 27 | @echo ' make clean cleanup all temporary files' 28 | @echo 29 | 30 | install: 31 | @test -d "$(VIRTUAL_ENV)" || mkdir -p "$(VIRTUAL_ENV)" 32 | @test -x "$(VIRTUAL_ENV)/bin/python" || virtualenv --python=$(PYTHON) --quiet "$(VIRTUAL_ENV)" 33 | @pip install --quiet --requirement=requirements.txt 34 | @pip uninstall --yes $(PACKAGE_NAME) &>/dev/null || true 35 | @pip install --quiet --no-deps --ignore-installed . 36 | 37 | reset: 38 | @$(MAKE) clean 39 | @rm -Rf "$(VIRTUAL_ENV)" 40 | @$(MAKE) install 41 | 42 | check: install 43 | @pip install --upgrade --quiet --requirement=requirements-checks.txt 44 | @flake8 45 | 46 | test: install 47 | @pip install --quiet --requirement=requirements-tests.txt 48 | @py.test --cov --cov-report=html --no-cov-on-fail 49 | @coverage report --fail-under=90 50 | 51 | tox: install 52 | @pip install --quiet tox 53 | @tox 54 | 55 | docs: install 56 | @pip install --quiet sphinx 57 | @cd docs && sphinx-build -nWb html -d build/doctrees . build/html 58 | 59 | publish: install 60 | @git push origin && git push --tags origin 61 | @$(MAKE) clean 62 | @pip install --quiet twine wheel 63 | @python setup.py sdist bdist_wheel 64 | @twine upload dist/* 65 | @$(MAKE) clean 66 | 67 | clean: 68 | @rm -Rf *.egg .cache .coverage .tox build dist docs/build htmlcov 69 | @find -depth -type d -name __pycache__ -exec rm -Rf {} \; 70 | @find -type f -name '*.pyc' -delete 71 | 72 | .PHONY: default install reset check test tox docs publish clean 73 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | property-manager: Useful property variants for Python programming 2 | ================================================================= 3 | 4 | .. image:: https://travis-ci.org/xolox/python-property-manager.svg?branch=master 5 | :target: https://travis-ci.org/xolox/python-property-manager 6 | 7 | .. image:: https://coveralls.io/repos/github/xolox/python-property-manager/badge.svg?branch=master 8 | :target: https://coveralls.io/github/xolox/python-property-manager?branch=master 9 | 10 | The `property-manager` package defines several custom property_ variants for 11 | Python programming including required properties, writable properties, cached 12 | properties, etc. It's currently tested on Python 2.7, 3.5, 3.6, 3.7, 3.8 and 13 | PyPy. For usage instructions please refer to the documentation_. 14 | 15 | .. contents:: 16 | :local: 17 | 18 | Status 19 | ------ 20 | 21 | The `property-manager` package came into existence as a submodule of my 22 | executor_ package where I wanted to define classes with a lot of properties 23 | that had a default value which was computed on demand but also needed to 24 | support assignment to easily override the default value. 25 | 26 | Since I created that module I'd wanted to re-use it in a couple of other 27 | projects I was working on, but adding an `executor` dependency just for the 28 | `property_manager` submodule felt kind of ugly. 29 | 30 | This is when I decided that it was time for the `property-manager` package to 31 | be created. When I extracted the submodule from `executor` I significantly 32 | changed its implementation (making the code more robust and flexible) and 33 | improved the tests, documentation and coverage in the process. 34 | 35 | Installation 36 | ------------ 37 | 38 | The `property-manager` package is available on PyPI_ which means installation 39 | should be as simple as: 40 | 41 | .. code-block:: sh 42 | 43 | $ pip install property-manager 44 | 45 | There's actually a multitude of ways to install Python packages (e.g. the `per 46 | user site-packages directory`_, `virtual environments`_ or just installing 47 | system wide) and I have no intention of getting into that discussion here, so 48 | if this intimidates you then read up on your options before returning to these 49 | instructions ;-). 50 | 51 | Usage 52 | ----- 53 | 54 | This section shows how to use the most useful property subclasses. Please refer 55 | to the documentation_ for more detailed information. 56 | 57 | .. contents:: 58 | :local: 59 | 60 | Writable properties 61 | ~~~~~~~~~~~~~~~~~~~ 62 | 63 | Writable properties with a computed default value are easy to create using the 64 | writable_property_ decorator: 65 | 66 | .. code-block:: python 67 | 68 | from random import random 69 | from property_manager import writable_property 70 | 71 | class WritablePropertyDemo(object): 72 | 73 | @writable_property 74 | def change_me(self): 75 | return random() 76 | 77 | First let's see how the computed default value behaves: 78 | 79 | >>> instance = WritablePropertyDemo() 80 | >>> print(instance.change_me) 81 | 0.13692489329941815 82 | >>> print(instance.change_me) 83 | 0.8664002331885933 84 | 85 | As you can see the value is recomputed each time. Now we'll assign it a value: 86 | 87 | >>> instance.change_me = 42 88 | >>> print(instance.change_me) 89 | 42 90 | 91 | From this point onwards `change_me` will be the number 42_ and it's impossible 92 | to revert back to the computed value: 93 | 94 | >>> delattr(instance, 'change_me') 95 | Traceback (most recent call last): 96 | File "property_manager/__init__.py", line 584, in __delete__ 97 | raise AttributeError(msg % (obj.__class__.__name__, self.__name__)) 98 | AttributeError: 'WritablePropertyDemo' object attribute 'change_me' is read-only 99 | 100 | If you're looking for a property that supports both assignment and deletion 101 | (clearing the assigned value) you can use mutable_property_. 102 | 103 | Required properties 104 | ~~~~~~~~~~~~~~~~~~~ 105 | 106 | The required_property_ decorator can be used to create required properties: 107 | 108 | .. code-block:: python 109 | 110 | from property_manager import PropertyManager, required_property 111 | 112 | class RequiredPropertyDemo(PropertyManager): 113 | 114 | @required_property 115 | def important(self): 116 | """A very important attribute.""" 117 | 118 | What does it mean for a property to be required? Let's create an instance of 119 | the class and find out: 120 | 121 | >>> instance = RequiredPropertyDemo() 122 | Traceback (most recent call last): 123 | File "property_manager/__init__.py", line 131, in __init__ 124 | raise TypeError("%s (%s)" % (msg, concatenate(missing_properties))) 125 | TypeError: missing 1 required argument (important) 126 | 127 | So the constructor of the class raises an exception when the property hasn't 128 | been given a value. We can give the property a value by providing keyword 129 | arguments to the constructor: 130 | 131 | >>> instance = RequiredPropertyDemo(important=42) 132 | >>> print(instance) 133 | RequiredPropertyDemo(important=42) 134 | 135 | We can also assign a new value to the property: 136 | 137 | >>> instance.important = 13 138 | >>> print(instance) 139 | RequiredPropertyDemo(important=13) 140 | 141 | Cached properties 142 | ~~~~~~~~~~~~~~~~~ 143 | 144 | Two kinds of cached properties are supported, we'll show both here: 145 | 146 | .. code-block:: python 147 | 148 | from random import random 149 | from property_manager import cached_property, lazy_property 150 | 151 | class CachedPropertyDemo(object): 152 | 153 | @cached_property 154 | def expensive(self): 155 | print("Calculating expensive property ..") 156 | return random() 157 | 158 | @lazy_property 159 | def non_idempotent(self): 160 | print("Calculating non-idempotent property ..") 161 | return random() 162 | 163 | The properties created by the cached_property_ decorator compute the 164 | property's value on demand and cache the result: 165 | 166 | >>> instance = CachedPropertyDemo() 167 | >>> print(instance.expensive) 168 | Calculating expensive property .. 169 | 0.763863180683 170 | >>> print(instance.expensive) 171 | 0.763863180683 172 | 173 | The property's cached value can be invalidated in order to recompute its value: 174 | 175 | >>> del instance.expensive 176 | >>> print(instance.expensive) 177 | Calculating expensive property .. 178 | 0.396322737214 179 | >>> print(instance.expensive) 180 | 0.396322737214 181 | 182 | Now that you understand cached_property_, explaining lazy_property_ is very 183 | simple: It simply doesn't support invalidation of cached values! Here's how 184 | that works in practice: 185 | 186 | >>> instance.non_idempotent 187 | Calculating non-idempotent property .. 188 | 0.27632566561900895 189 | >>> instance.non_idempotent 190 | 0.27632566561900895 191 | >>> del instance.non_idempotent 192 | Traceback (most recent call last): 193 | File "property_manager/__init__.py", line 499, in __delete__ 194 | raise AttributeError(msg % (obj.__class__.__name__, self.__name__)) 195 | AttributeError: 'CachedPropertyDemo' object attribute 'non_idempotent' is read-only 196 | >>> instance.non_idempotent 197 | 0.27632566561900895 198 | 199 | Properties based on environment variables 200 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 201 | 202 | The constructor of the custom_property_ class (and its subclasses) accepts the 203 | keyword argument `environment_variable` which can be provided to get the 204 | property's value from the environment: 205 | 206 | .. code-block:: python 207 | 208 | from random import random 209 | from property_manager import mutable_property 210 | 211 | class EnvironmentPropertyDemo(object): 212 | 213 | @mutable_property(environment_variable='WHATEVER_YOU_WANT') 214 | def environment_based(self): 215 | return 'some-default-value' 216 | 217 | By default the property's value is computed as expected: 218 | 219 | >>> instance = EnvironmentPropertyDemo() 220 | >>> print(instance.environment_based) 221 | some-default-value 222 | 223 | When the environment variable is set it overrides the computed value: 224 | 225 | >>> os.environ['WHATEVER_YOU_WANT'] = '42' 226 | >>> print(instance.environment_based) 227 | 42 228 | 229 | Assigning a value to the property overrides the value from the environment: 230 | 231 | >>> instance.environment_based = '13' 232 | >>> print(instance.environment_based) 233 | 13 234 | 235 | Deleting the property clears the assigned value so that the property falls back 236 | to the environment: 237 | 238 | >>> delattr(instance, 'environment_based') 239 | >>> print(instance.environment_based) 240 | 42 241 | 242 | If we now clear the environment variable as well then the property falls back 243 | to the computed value: 244 | 245 | >>> os.environ.pop('WHATEVER_YOU_WANT') 246 | '42' 247 | >>> print(instance.environment_based) 248 | some-default-value 249 | 250 | Support for setters and deleters 251 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 252 | 253 | All of the custom property classes support setters and deleters just like 254 | Python's ``property`` decorator does. 255 | 256 | The `PropertyManager` class 257 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 258 | 259 | When you define a class that inherits from the PropertyManager_ class the 260 | following behavior is made available to your class: 261 | 262 | - Required properties raise an exception if they're not set. 263 | 264 | - The values of writable properties can be set by passing 265 | keyword arguments to the constructor of your class. 266 | 267 | - The `repr()` of your objects will render the name of the class and the names 268 | and values of all properties. Individual properties can easily be excluded 269 | from the `repr()` output. 270 | 271 | - The `clear_cached_properties()`_ method can be used to invalidate the cached 272 | values of all cached properties at once. 273 | 274 | Additionally you can use the property_manager.sphinx_ module as a Sphinx 275 | extension to automatically generate boilerplate documentation that provides an 276 | overview of base classes, properties, public methods and special methods. 277 | 278 | Similar projects 279 | ---------------- 280 | 281 | The Python Package Index contains quite a few packages that provide custom 282 | properties with similar semantics: 283 | 284 | `cached-property `_ 285 | My personal favorite until I wrote my own :-). This package provides several 286 | cached property variants. It supports threading and time based cache 287 | invalidation which `property-manager` doesn't support. 288 | 289 | `lazy-property `_ 290 | This package provides two cached property variants: a read only property and 291 | a writable property. Both variants cache computed values indefinitely. 292 | 293 | `memoized-property `_ 294 | This package provides a single property variant which simply caches computed 295 | values indefinitely. 296 | 297 | `property-caching `_ 298 | This package provides several cached property variants supporting class 299 | properties, object properties and cache invalidation. 300 | 301 | `propertylib `_ 302 | This package uses metaclasses to implement an alternative syntax for defining 303 | computed properties. It defines several property variants with semantics that 304 | are similar to those defined by the `property-manager` package. 305 | 306 | `rwproperty `_ 307 | This package implements computed, writable properties using an alternative 308 | syntax to define the properties. 309 | 310 | Distinguishing features 311 | ~~~~~~~~~~~~~~~~~~~~~~~ 312 | 313 | Despite all of the existing Python packages discussed above I decided to create 314 | and publish the `property-manager` package because it was fun to get to know 315 | Python's `descriptor protocol`_ and I had several features in mind I couldn't 316 | find anywhere else: 317 | 318 | - A superclass that sets writable properties based on constructor arguments. 319 | 320 | - A superclass that understands required properties and raises a clear 321 | exception if a required property is not properly initialized. 322 | 323 | - Clear disambiguation between lazy properties (whose computed value is cached 324 | but cannot be invalidated because it would compromise internal state) and 325 | cached properties (whose computed value is cached but can be invalidated to 326 | compute a fresh value). 327 | 328 | - An easy way to quickly invalidate all cached properties of an object. 329 | 330 | - An easy way to change the semantics of custom properties, e.g. what if the 331 | user wants a writable cached property? With `property-manager` it is trivial 332 | to define new property variants by combining existing semantics: 333 | 334 | .. code-block:: python 335 | 336 | from property_manager import cached_property 337 | 338 | class WritableCachedPropertyDemo(object): 339 | 340 | @cached_property(writable=True) 341 | def expensive_overridable_attribute(self): 342 | """Expensive calculations go here.""" 343 | 344 | The example above creates a new anonymous class and then immediately uses 345 | that to decorate the method. We could have given the class a name though: 346 | 347 | .. code-block:: python 348 | 349 | from property_manager import cached_property 350 | 351 | writable_cached_property = cached_property(writable=True) 352 | 353 | class WritableCachedPropertyDemo(object): 354 | 355 | @writable_cached_property 356 | def expensive_overridable_attribute(self): 357 | """Expensive calculations go here.""" 358 | 359 | By giving the new property variant a name it can be reused. We can go one 360 | step further and properly document the new property variant: 361 | 362 | .. code-block:: python 363 | 364 | from property_manager import cached_property 365 | 366 | class writable_cached_property(cached_property): 367 | 368 | """A cached property that supports assignment.""" 369 | 370 | writable = True 371 | 372 | class WritableCachedPropertyDemo(object): 373 | 374 | @writable_cached_property 375 | def expensive_overridable_attribute(self): 376 | """Expensive calculations go here.""" 377 | 378 | I've used computed properties for years in Python and over those years I've 379 | learned that different Python projects have different requirements from 380 | custom property variants. Defining every possible permutation up front is 381 | madness, but I think that the flexibility with which the `property-manager` 382 | package enables adaptation gets a long way. This was the one thing that 383 | bothered me the most about all of the other Python packages that implement 384 | property variants: They are not easily adapted to unanticipated use cases. 385 | 386 | Contact 387 | ------- 388 | 389 | The latest version of `property-manager` is available on PyPI_ and GitHub_. The 390 | documentation is hosted on `Read the Docs`_ and includes a changelog_. For bug 391 | reports please create an issue on GitHub_. If you have questions, suggestions, 392 | etc. feel free to send me an e-mail at `peter@peterodding.com`_. 393 | 394 | License 395 | ------- 396 | 397 | This software is licensed under the `MIT license`_. 398 | 399 | © 2020 Peter Odding. 400 | 401 | 402 | .. External references: 403 | .. _42: https://en.wikipedia.org/wiki/42_(number)#The_Hitchhiker.27s_Guide_to_the_Galaxy 404 | .. _cached_property: https://property-manager.readthedocs.io/en/latest/api.html#property_manager.cached_property 405 | .. _changelog: https://property-manager.readthedocs.io/en/latest/changelog.html 406 | .. _clear_cached_properties(): https://property-manager.readthedocs.io/en/latest/api.html#property_manager.PropertyManager.clear_cached_properties 407 | .. _custom_property: https://property-manager.readthedocs.io/en/latest/api.html#property_manager.custom_property 408 | .. _descriptor protocol: https://docs.python.org/2/howto/descriptor.html 409 | .. _documentation: https://property-manager.readthedocs.io 410 | .. _executor: https://executor.readthedocs.org/en/latest/ 411 | .. _GitHub: https://github.com/xolox/python-property-manager 412 | .. _lazy_property: https://property-manager.readthedocs.io/en/latest/api.html#property_manager.lazy_property 413 | .. _MIT license: http://en.wikipedia.org/wiki/MIT_License 414 | .. _mutable_property: https://property-manager.readthedocs.io/en/latest/api.html#property_manager.mutable_property 415 | .. _per user site-packages directory: https://www.python.org/dev/peps/pep-0370/ 416 | .. _peter@peterodding.com: peter@peterodding.com 417 | .. _property: https://docs.python.org/2/library/functions.html#property 418 | .. _property_manager.sphinx: https://property-manager.readthedocs.io/en/latest/api.html#property_manager.sphinx 419 | .. _PropertyManager: https://property-manager.readthedocs.io/en/latest/api.html#property_manager.PropertyManager 420 | .. _PyPI: https://pypi.python.org/pypi/property-manager 421 | .. _Read the Docs: https://property-manager.readthedocs.io 422 | .. _required_property: https://property-manager.readthedocs.io/en/latest/api.html#property_manager.required_property 423 | .. _virtual environments: http://docs.python-guide.org/en/latest/dev/virtualenvs/ 424 | .. _writable_property: https://property-manager.readthedocs.io/en/latest/api.html#property_manager.writable_property 425 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API documentation 2 | ================= 3 | 4 | The following API documentation was automatically generated from the source 5 | code of `property-manager` |release|: 6 | 7 | .. contents:: 8 | :local: 9 | 10 | :mod:`property_manager` 11 | ----------------------- 12 | 13 | .. automodule:: property_manager 14 | :members: 15 | 16 | 17 | :mod:`property_manager.sphinx` 18 | ------------------------------ 19 | 20 | .. automodule:: property_manager.sphinx 21 | :members: 22 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Useful property variants for Python programming. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 2, 2020 5 | # URL: https://property-manager.readthedocs.io 6 | 7 | """Sphinx documentation configuration for the `property-manager` package.""" 8 | 9 | import os 10 | import sys 11 | 12 | # Add the property-manager source distribution's root directory to the module path. 13 | sys.path.insert(0, os.path.abspath(os.pardir)) 14 | 15 | # -- General configuration ----------------------------------------------------- 16 | 17 | # Sphinx extension module names. 18 | extensions = [ 19 | 'sphinx.ext.autodoc', 20 | 'sphinx.ext.doctest', 21 | 'sphinx.ext.inheritance_diagram', 22 | 'sphinx.ext.intersphinx', 23 | 'sphinx.ext.viewcode', 24 | 'humanfriendly.sphinx', 25 | 'property_manager.sphinx', 26 | ] 27 | 28 | # Sort members by the source order instead of alphabetically. 29 | autodoc_member_order = 'bysource' 30 | 31 | # Paths that contain templates, relative to this directory. 32 | templates_path = ['templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The master toctree document. 38 | master_doc = 'index' 39 | 40 | # General information about the project. 41 | project = 'property-manager' 42 | copyright = '2020, Peter Odding' 43 | 44 | # The version info for the project you're documenting, acts as replacement for 45 | # |version| and |release|, also used in various other places throughout the 46 | # built documents. 47 | 48 | # Find the package version and make it the release. 49 | from property_manager import __version__ as property_manager_version # noqa 50 | 51 | # The short X.Y version. 52 | version = '.'.join(property_manager_version.split('.')[:2]) 53 | 54 | # The full version, including alpha/beta/rc tags. 55 | release = property_manager_version 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | language = 'en' 60 | 61 | # List of patterns, relative to source directory, that match files and 62 | # directories to ignore when looking for source files. 63 | exclude_patterns = ['build'] 64 | 65 | # If true, '()' will be appended to :func: etc. cross-reference text. 66 | add_function_parentheses = True 67 | 68 | # The name of the Pygments (syntax highlighting) style to use. 69 | pygments_style = 'sphinx' 70 | 71 | # Refer to the Python standard library. 72 | # From: http://twistedmatrix.com/trac/ticket/4582. 73 | intersphinx_mapping = { 74 | 'humanfriendly': ('https://humanfriendly.readthedocs.io/en/latest', None), 75 | 'python2': ('https://docs.python.org/2', None), 76 | 'python3': ('https://docs.python.org/3', None), 77 | 'verboselogs': ('https://verboselogs.readthedocs.io/en/latest', None), 78 | } 79 | 80 | # -- Options for HTML output --------------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | html_theme = 'nature' 85 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Documentation for property-manager 2 | ================================== 3 | 4 | Welcome to the documentation of `property-manager` version |release|! 5 | The following sections are available: 6 | 7 | .. contents:: 8 | :local: 9 | 10 | User documentation 11 | ------------------ 12 | 13 | The readme is the best place to start reading and provides examples: 14 | 15 | .. toctree:: 16 | readme.rst 17 | 18 | API documentation 19 | ----------------- 20 | 21 | The following API documentation is automatically generated from the source code: 22 | 23 | .. toctree:: 24 | api.rst 25 | 26 | Change log 27 | ---------- 28 | 29 | The change log lists notable changes to the project: 30 | 31 | .. toctree:: 32 | changelog.rst 33 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /property_manager/__init__.py: -------------------------------------------------------------------------------- 1 | # Useful property variants for Python programming. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 2, 2020 5 | # URL: https://property-manager.readthedocs.io 6 | 7 | """ 8 | Useful :class:`property` variants for Python programming. 9 | 10 | Introduction 11 | ============ 12 | 13 | The :mod:`property_manager` module defines several :class:`property` variants 14 | that implement Python's `descriptor protocol`_ to provide decorators that turn 15 | methods into computed properties with several additional features. 16 | 17 | Custom property types 18 | --------------------- 19 | 20 | Here's an overview of the predefined property variants and their supported 21 | operations: 22 | 23 | ========================== ========== ============ ========== ======= 24 | Variant Assignment Reassignment Deletion Caching 25 | ========================== ========== ============ ========== ======= 26 | :class:`custom_property` No No No No 27 | :class:`writable_property` Yes Yes No No 28 | :class:`mutable_property` Yes Yes Yes No 29 | :class:`required_property` Yes Yes No No 30 | :class:`key_property` Yes No No No 31 | :class:`lazy_property` No No No Yes 32 | :class:`cached_property` No No Yes Yes 33 | ========================== ========== ============ ========== ======= 34 | 35 | If you want a different combination of supported options (for example a cached 36 | property that supports assignment) this is also possible, please take a look at 37 | :class:`custom_property.__new__()`. 38 | 39 | The following inheritance diagram shows how the predefined :class:`property` 40 | variants relate to each other: 41 | 42 | .. inheritance-diagram:: property_manager.custom_property \ 43 | property_manager.writable_property \ 44 | property_manager.mutable_property \ 45 | property_manager.required_property \ 46 | property_manager.key_property \ 47 | property_manager.lazy_property \ 48 | property_manager.cached_property 49 | :parts: 1 50 | 51 | The property manager superclass 52 | ------------------------------- 53 | 54 | In addition to these :class:`property` variants the :mod:`property_manager` 55 | module also defines a :class:`PropertyManager` class which implements several 56 | related enhancements: 57 | 58 | - Keyword arguments to the constructor can be used to set writable properties 59 | created using any of the :class:`property` variants defined by the 60 | :mod:`property_manager` module. 61 | 62 | - Required properties without an assigned value will cause the constructor 63 | to raise an appropriate exception (:exc:`~exceptions.TypeError`). 64 | 65 | - The :func:`repr()` of :class:`PropertyManager` objects shows the names and 66 | values of all properties. Individual properties can be omitted from the 67 | :func:`repr()` output by setting the :attr:`~custom_property.repr` option to 68 | :data:`False`. 69 | 70 | Logging 71 | ======= 72 | 73 | The :mod:`property_manager` module emits log messages at the custom log level 74 | :data:`~verboselogs.SPAM` which is considered *more* verbose than :mod:`DEBUG 75 | `, so if you want these messages to be logged make sure they're not 76 | being ignored based on their level. 77 | 78 | Classes 79 | ======= 80 | 81 | .. _descriptor protocol: https://docs.python.org/2/howto/descriptor.html 82 | """ 83 | 84 | # Standard library modules. 85 | import os 86 | import sys 87 | import textwrap 88 | 89 | try: 90 | # Python 3.3 and newer. 91 | from collections.abc import Hashable 92 | except ImportError: 93 | # Python 2.7. 94 | from collections import Hashable 95 | 96 | # External dependencies. 97 | from humanfriendly import coerce_boolean 98 | from humanfriendly.text import compact, concatenate, format, pluralize 99 | from verboselogs import VerboseLogger 100 | 101 | try: 102 | # Check if `basestring' is defined (Python 2). 103 | basestring = basestring 104 | except NameError: 105 | # Alias basestring to str in Python 3. 106 | basestring = str 107 | 108 | __version__ = '3.0' 109 | """Semi-standard module versioning.""" 110 | 111 | SPHINX_ACTIVE = 'sphinx' in sys.modules 112 | """ 113 | :data:`True` when Sphinx_ is running, :data:`False` otherwise. 114 | 115 | We detect whether Sphinx is running by checking for the presence of the 116 | 'sphinx' key in :data:`sys.modules`. The result determines the default 117 | value of :data:`USAGE_NOTES_ENABLED`. 118 | """ 119 | 120 | USAGE_NOTES_VARIABLE = 'PROPERTY_MANAGER_USAGE_NOTES' 121 | """The name of the environment variable that controls whether usage notes are enabled (a string).""" 122 | 123 | USAGE_NOTES_ENABLED = (coerce_boolean(os.environ[USAGE_NOTES_VARIABLE]) 124 | if USAGE_NOTES_VARIABLE in os.environ 125 | else SPHINX_ACTIVE) 126 | """ 127 | :data:`True` if usage notes are enabled, :data:`False` otherwise. 128 | 129 | This defaults to the environment variable :data:`USAGE_NOTES_VARIABLE` (coerced 130 | using :func:`~humanfriendly.coerce_boolean()`) when available, otherwise 131 | :data:`SPHINX_ACTIVE` determines the default value. 132 | 133 | Usage notes are only injected when Sphinx is running because of performance. 134 | It's nothing critical of course, but modifying hundreds or thousands of 135 | docstrings that no one is going to look at seems rather pointless :-). 136 | """ 137 | 138 | NOTHING = object() 139 | """A unique object instance used to detect missing attributes.""" 140 | 141 | CUSTOM_PROPERTY_NOTE = compact(""" 142 | The :attr:`{name}` property is a :class:`~{type}`. 143 | """) 144 | 145 | DYNAMIC_PROPERTY_NOTE = compact(""" 146 | The :attr:`{name}` property is a :class:`~{type}`. 147 | """) 148 | 149 | ENVIRONMENT_PROPERTY_NOTE = compact(""" 150 | If the environment variable ``${variable}`` is set it overrides the 151 | computed value of this property. 152 | """) 153 | 154 | REQUIRED_PROPERTY_NOTE = compact(""" 155 | You are required to provide a value for this property by calling the 156 | constructor of the class that defines the property with a keyword argument 157 | named `{name}` (unless a custom constructor is defined, in this case please 158 | refer to the documentation of that constructor). 159 | """) 160 | 161 | KEY_PROPERTY_NOTE = compact(""" 162 | Once this property has been assigned a value you are not allowed to assign 163 | a new value to the property. 164 | """) 165 | 166 | WRITABLE_PROPERTY_NOTE = compact(""" 167 | You can change the value of this property using normal attribute assignment 168 | syntax. 169 | """) 170 | 171 | CACHED_PROPERTY_NOTE = compact(""" 172 | This property's value is computed once (the first time it is accessed) and 173 | the result is cached. 174 | """) 175 | 176 | RESETTABLE_CACHED_PROPERTY_NOTE = compact(""" 177 | To clear the cached value you can use :keyword:`del` or 178 | :func:`delattr()`. 179 | """) 180 | 181 | RESETTABLE_WRITABLE_PROPERTY_NOTE = compact(""" 182 | To reset it to its default (computed) value you can use :keyword:`del` or 183 | :func:`delattr()`. 184 | """) 185 | 186 | # Initialize a logger for this module. 187 | logger = VerboseLogger(__name__) 188 | 189 | 190 | def set_property(obj, name, value): 191 | """ 192 | Set or override the value of a property. 193 | 194 | :param obj: The object that owns the property. 195 | :param name: The name of the property (a string). 196 | :param value: The new value for the property. 197 | 198 | This function directly modifies the :attr:`~object.__dict__` of the given 199 | object and as such it avoids any interaction with object properties. This 200 | is intentional: :func:`set_property()` is meant to be used by extensions of 201 | the `property-manager` project and by user defined setter methods. 202 | """ 203 | logger.spam("Setting value of %s property to %r ..", format_property(obj, name), value) 204 | obj.__dict__[name] = value 205 | 206 | 207 | def clear_property(obj, name): 208 | """ 209 | Clear the assigned or cached value of a property. 210 | 211 | :param obj: The object that owns the property. 212 | :param name: The name of the property (a string). 213 | 214 | This function directly modifies the :attr:`~object.__dict__` of the given 215 | object and as such it avoids any interaction with object properties. This 216 | is intentional: :func:`clear_property()` is meant to be used by extensions 217 | of the `property-manager` project and by user defined deleter methods. 218 | """ 219 | logger.spam("Clearing value of %s property ..", format_property(obj, name)) 220 | obj.__dict__.pop(name, None) 221 | 222 | 223 | def format_property(obj, name): 224 | """ 225 | Format an object property's dotted name. 226 | 227 | :param obj: The object that owns the property. 228 | :param name: The name of the property (a string). 229 | :returns: The dotted path (a string). 230 | """ 231 | return "%s.%s" % (obj.__class__.__name__, name) 232 | 233 | 234 | class PropertyManager(object): 235 | 236 | """ 237 | Optional superclass for classes that use the computed properties from this module. 238 | 239 | Provides support for required properties, setting of properties in the 240 | constructor and generating a useful textual representation of objects with 241 | properties. 242 | """ 243 | 244 | def __init__(self, **kw): 245 | """ 246 | Initialize a :class:`PropertyManager` object. 247 | 248 | :param kw: Any keyword arguments are passed on to :func:`set_properties()`. 249 | """ 250 | self.set_properties(**kw) 251 | missing_properties = self.missing_properties 252 | if missing_properties: 253 | msg = "missing %s" % pluralize(len(missing_properties), "required argument") 254 | raise TypeError("%s (%s)" % (msg, concatenate(missing_properties))) 255 | 256 | def set_properties(self, **kw): 257 | """ 258 | Set instance properties from keyword arguments. 259 | 260 | :param kw: Every keyword argument is used to assign a value to the 261 | instance property whose name matches the keyword argument. 262 | :raises: :exc:`~exceptions.TypeError` when a keyword argument doesn't 263 | match a :class:`property` on the given object. 264 | """ 265 | for name, value in kw.items(): 266 | if self.have_property(name): 267 | setattr(self, name, value) 268 | else: 269 | msg = "got an unexpected keyword argument %r" 270 | raise TypeError(msg % name) 271 | 272 | @property 273 | def key_properties(self): 274 | """A sorted list of strings with the names of any :attr:`~custom_property.key` properties.""" 275 | return self.find_properties(key=True) 276 | 277 | @property 278 | def key_values(self): 279 | """A tuple of tuples with (name, value) pairs for each name in :attr:`key_properties`.""" 280 | return tuple((name, getattr(self, name)) for name in self.key_properties) 281 | 282 | @property 283 | def missing_properties(self): 284 | """ 285 | The names of key and/or required properties that are missing. 286 | 287 | This is a list of strings with the names of key and/or required 288 | properties that either haven't been set or are set to :data:`None`. 289 | """ 290 | names = sorted(set(self.required_properties) | set(self.key_properties)) 291 | return [n for n in names if getattr(self, n, None) is None] 292 | 293 | @property 294 | def repr_properties(self): 295 | """ 296 | The names of the properties rendered by :func:`__repr__()` (a list of strings). 297 | 298 | When :attr:`key_properties` is nonempty the names of the key properties 299 | are returned, otherwise a more complex selection is made (of properties 300 | defined by subclasses of :class:`PropertyManager` whose 301 | :attr:`~custom_property.repr` is :data:`True`). 302 | """ 303 | return self.key_properties or [ 304 | name for name in self.find_properties(repr=True) 305 | if not hasattr(PropertyManager, name) 306 | ] 307 | 308 | @property 309 | def required_properties(self): 310 | """A sorted list of strings with the names of any :attr:`~custom_property.required` properties.""" 311 | return self.find_properties(required=True) 312 | 313 | def find_properties(self, **options): 314 | """ 315 | Find an object's properties (of a certain type). 316 | 317 | :param options: Passed on to :func:`have_property()` to enable 318 | filtering properties by the operations they support. 319 | :returns: A sorted list of strings with the names of properties. 320 | """ 321 | # We don't explicitly sort our results here because the dir() function 322 | # is documented to sort its results alphabetically. 323 | return [n for n in dir(self) if self.have_property(n, **options)] 324 | 325 | def have_property(self, name, **options): 326 | """ 327 | Check if the object has a property (of a certain type). 328 | 329 | :param name: The name of the property (a string). 330 | :param options: Any keyword arguments give the name of an option 331 | (one of :attr:`~custom_property.writable`, 332 | :attr:`~custom_property.resettable`, 333 | :attr:`~custom_property.cached`, 334 | :attr:`~custom_property.required`, 335 | :attr:`~custom_property.key`, 336 | :attr:`~custom_property.repr`) and an expected value 337 | (:data:`True` or :data:`False`). Filtering on more than 338 | one option is supported. 339 | :returns: :data:`True` if the object has a property with the expected 340 | options enabled/disabled, :data:`False` otherwise. 341 | """ 342 | property_type = getattr(self.__class__, name, None) 343 | if isinstance(property_type, property): 344 | if options: 345 | return all(getattr(property_type, n, None) == v or 346 | n == 'repr' and v is True and getattr(property_type, n, None) is not False 347 | for n, v in options.items()) 348 | else: 349 | return True 350 | else: 351 | return False 352 | 353 | def clear_cached_properties(self): 354 | """Clear cached properties so that their values are recomputed.""" 355 | for name in self.find_properties(cached=True, resettable=True): 356 | delattr(self, name) 357 | 358 | def render_properties(self, *names): 359 | """ 360 | Render a human friendly string representation of an object with computed properties. 361 | 362 | :param names: Each positional argument gives the name of a property 363 | to include in the rendered object representation. 364 | :returns: The rendered object representation (a string). 365 | 366 | This method generates a user friendly textual representation for 367 | objects that use computed properties created using the 368 | :mod:`property_manager` module. 369 | """ 370 | fields = [] 371 | for name in names: 372 | value = getattr(self, name, None) 373 | if value is not None or name in self.key_properties: 374 | fields.append("%s=%r" % (name, value)) 375 | return "%s(%s)" % (self.__class__.__name__, ", ".join(fields)) 376 | 377 | def __eq__(self, other): 378 | """Enable equality comparison and hashing for :class:`PropertyManager` subclasses.""" 379 | our_key = self.key_values 380 | return (our_key == other.key_values 381 | if our_key and isinstance(other, PropertyManager) 382 | else NotImplemented) 383 | 384 | def __ne__(self, other): 385 | """Enable non-equality comparison for :class:`PropertyManager` subclasses.""" 386 | our_key = self.key_values 387 | return (our_key != other.key_values 388 | if our_key and isinstance(other, PropertyManager) 389 | else NotImplemented) 390 | 391 | def __lt__(self, other): 392 | """Enable "less than" comparison for :class:`PropertyManager` subclasses.""" 393 | our_key = self.key_values 394 | return (our_key < other.key_values 395 | if our_key and isinstance(other, PropertyManager) 396 | else NotImplemented) 397 | 398 | def __le__(self, other): 399 | """Enable "less than or equal" comparison for :class:`PropertyManager` subclasses.""" 400 | our_key = self.key_values 401 | return (our_key <= other.key_values 402 | if our_key and isinstance(other, PropertyManager) 403 | else NotImplemented) 404 | 405 | def __gt__(self, other): 406 | """Enable "greater than" comparison for :class:`PropertyManager` subclasses.""" 407 | our_key = self.key_values 408 | return (our_key > other.key_values 409 | if our_key and isinstance(other, PropertyManager) 410 | else NotImplemented) 411 | 412 | def __ge__(self, other): 413 | """Enable "greater than or equal" comparison for :class:`PropertyManager` subclasses.""" 414 | our_key = self.key_values 415 | return (our_key >= other.key_values 416 | if our_key and isinstance(other, PropertyManager) 417 | else NotImplemented) 418 | 419 | def __hash__(self): 420 | """ 421 | Enable hashing for :class:`PropertyManager` subclasses. 422 | 423 | This method makes it possible to add :class:`PropertyManager` objects 424 | to sets and use them as dictionary keys. The hashes computed by this 425 | method are based on the values in :attr:`key_values`. 426 | """ 427 | return hash(PropertyManager) ^ hash(self.key_values) 428 | 429 | def __repr__(self): 430 | """ 431 | Render a human friendly string representation of an object with computed properties. 432 | 433 | :returns: The rendered object representation (a string). 434 | 435 | This method uses :func:`render_properties()` to render the properties 436 | whose names are given by :attr:`repr_properties`. When the object 437 | doesn't have any key properties, :func:`__repr__()` assumes that 438 | all of the object's properties are idempotent and may be evaluated 439 | at any given time without worrying too much about performance (refer 440 | to the :attr:`~custom_property.repr` option for an escape hatch). 441 | """ 442 | return self.render_properties(*self.repr_properties) 443 | 444 | 445 | class custom_property(property): 446 | 447 | """ 448 | Custom :class:`property` subclass that supports additional features. 449 | 450 | The :class:`custom_property` class implements Python's `descriptor 451 | protocol`_ to provide a decorator that turns methods into computed 452 | properties with several additional features. 453 | 454 | .. _descriptor protocol: https://docs.python.org/2/howto/descriptor.html 455 | 456 | The additional features are controlled by attributes defined on the 457 | :class:`custom_property` class. These attributes (documented below) are 458 | intended to be changed by the constructor (:func:`__new__()`) and/or 459 | classes that inherit from :class:`custom_property`. 460 | """ 461 | 462 | cached = False 463 | """ 464 | If this attribute is set to :data:`True` the property's value is computed 465 | only once and then cached in an object's :attr:`~object.__dict__`. The next 466 | time you access the attribute's value the cached value is automatically 467 | returned. By combining the :attr:`cached` and :attr:`resettable` options 468 | you get a cached property whose cached value can be cleared. If the value 469 | should never be recomputed then don't enable the :attr:`resettable` 470 | option. 471 | 472 | :see also: :class:`cached_property` and :class:`lazy_property`. 473 | """ 474 | 475 | dynamic = False 476 | """ 477 | :data:`True` when the :class:`custom_property` subclass was dynamically 478 | constructed by :func:`__new__()`, :data:`False` otherwise. Used by 479 | :func:`compose_usage_notes()` to decide whether to link to the 480 | documentation of the subclass or not (because it's impossible to link to 481 | anonymous classes). 482 | """ 483 | 484 | environment_variable = None 485 | """ 486 | If this attribute is set to the name of an environment variable the 487 | property's value will default to the value of the environment variable. If 488 | the environment variable isn't set the property falls back to its computed 489 | value. 490 | """ 491 | 492 | key = False 493 | """ 494 | If this attribute is :data:`True` the property's name is included in the 495 | value of :attr:`~PropertyManager.key_properties` which means that the 496 | property's value becomes part of the "key" that is used to compare, sort 497 | and hash :class:`PropertyManager` objects. There are a few things to be 498 | aware of with regards to key properties and their values: 499 | 500 | - The property's value must be set during object initialization (the same 501 | as for :attr:`required` properties) and it cannot be changed after it is 502 | initially assigned a value (because allowing this would "compromise" 503 | the results of the :func:`~PropertyManager.__hash__()` method). 504 | - The property's value must be hashable (otherwise it can't be used by the 505 | :func:`~PropertyManager.__hash__()` method). 506 | 507 | :see also: :class:`key_property`. 508 | """ 509 | 510 | repr = True 511 | """ 512 | By default :func:`PropertyManager.__repr__()` includes the names and values 513 | of all properties that aren't :data:`None` in :func:`repr()` output. If you 514 | want to omit a certain property you can set :attr:`repr` to :data:`False`. 515 | 516 | Examples of why you would want to do this include property values that 517 | contain secrets or are expensive to calculate and data structures with 518 | cycles which cause :func:`repr()` to die a slow and horrible death :-). 519 | """ 520 | 521 | required = False 522 | """ 523 | If this attribute is set to :data:`True` the property requires a value to 524 | be set during the initialization of the object that owns the property. For 525 | this to work the class that owns the property needs to inherit from 526 | :class:`PropertyManager`. 527 | 528 | :see also: :class:`required_property`. 529 | 530 | The constructor of :class:`PropertyManager` will ensure that required 531 | properties are set to values that aren't :data:`None`. Required properties 532 | must be set by providing keyword arguments to the constructor of the class 533 | that inherits from :class:`PropertyManager`. When 534 | :func:`PropertyManager.__init__()` notices that required properties haven't 535 | been set it raises a :exc:`~exceptions.TypeError` similar to the type error 536 | raised by Python when required arguments are missing in a function call. 537 | Here's an example: 538 | 539 | .. code-block:: python 540 | 541 | from property_manager import PropertyManager, required_property, mutable_property 542 | 543 | class Example(PropertyManager): 544 | 545 | @required_property 546 | def important(self): 547 | "A very important attribute." 548 | 549 | @mutable_property 550 | def optional(self): 551 | "A not so important attribute." 552 | return 13 553 | 554 | Let's construct an instance of the class defined above: 555 | 556 | >>> Example() 557 | Traceback (most recent call last): 558 | File "property_manager/__init__.py", line 107, in __init__ 559 | raise TypeError("%s (%s)" % (msg, concatenate(missing_properties))) 560 | TypeError: missing 1 required argument ('important') 561 | 562 | As expected it complains that a required property hasn't been 563 | initialized. Here's how it's supposed to work: 564 | 565 | >>> Example(important=42) 566 | Example(important=42, optional=13) 567 | """ 568 | 569 | resettable = False 570 | """ 571 | If this attribute is set to :data:`True` the property can be reset to its 572 | default or computed value using :keyword:`del` and :func:`delattr()`. This 573 | works by removing the assigned or cached value from the object's 574 | :attr:`~object.__dict__`. 575 | 576 | :see also: :class:`mutable_property` and :class:`cached_property`. 577 | """ 578 | 579 | usage_notes = True 580 | """ 581 | If this attribute is :data:`True` :func:`inject_usage_notes()` is used to 582 | inject usage notes into the documentation of the property. You can set this 583 | attribute to :data:`False` to disable :func:`inject_usage_notes()`. 584 | """ 585 | 586 | writable = False 587 | """ 588 | If this attribute is set to :data:`True` the property supports assignment. 589 | The assigned value is stored in the :attr:`~object.__dict__` of the object 590 | that owns the property. 591 | 592 | :see also: :class:`writable_property`, :class:`mutable_property` and 593 | :class:`required_property`. 594 | 595 | A relevant note about how Python looks up attributes: When an attribute is 596 | looked up and exists in an object's :attr:`~object.__dict__` Python ignores 597 | any property (descriptor) by the same name and immediately returns the 598 | value that was found in the object's :attr:`~object.__dict__`. 599 | """ 600 | 601 | def __new__(cls, *args, **options): 602 | """ 603 | Constructor for :class:`custom_property` subclasses and instances. 604 | 605 | To construct a subclass: 606 | 607 | :param args: The first positional argument is used as the name of the 608 | subclass (defaults to 'customized_property'). 609 | :param options: Each keyword argument gives the name of an option 610 | (:attr:`writable`, :attr:`resettable`, :attr:`cached`, 611 | :attr:`required`, :attr:`environment_variable`, 612 | :attr:`repr`) and the value to use for that option 613 | (:data:`True`, :data:`False` or a string). 614 | :returns: A dynamically constructed subclass of 615 | :class:`custom_property` with the given options. 616 | 617 | To construct an instance: 618 | 619 | :param args: The first positional argument is the function that's 620 | called to compute the value of the property. 621 | :returns: A :class:`custom_property` instance corresponding to the 622 | class whose constructor was called. 623 | 624 | Here's an example of how the subclass constructor can be used to 625 | dynamically construct custom properties with specific options: 626 | 627 | .. code-block:: python 628 | 629 | from property_manager import custom_property 630 | 631 | class WritableCachedPropertyDemo(object): 632 | 633 | @custom_property(cached=True, writable=True) 634 | def customized_test_property(self): 635 | return 42 636 | 637 | The example above defines and uses a property whose computed value is 638 | cached and which supports assignment of new values. The example could 639 | have been made even simpler: 640 | 641 | .. code-block:: python 642 | 643 | from property_manager import cached_property 644 | 645 | class WritableCachedPropertyDemo(object): 646 | 647 | @cached_property(writable=True) 648 | def customized_test_property(self): 649 | return 42 650 | 651 | Basically you can take any of the custom property classes defined in 652 | the :mod:`property_manager` module and call the class with keyword 653 | arguments corresponding to the options you'd like to change. 654 | """ 655 | if options: 656 | # Keyword arguments construct subclasses. 657 | name = args[0] if args else 'customized_property' 658 | options['dynamic'] = True 659 | return type(name, (cls,), options) 660 | else: 661 | # Positional arguments construct instances. 662 | return super(custom_property, cls).__new__(cls, *args) 663 | 664 | def __init__(self, *args, **kw): 665 | """ 666 | Initialize a :class:`custom_property` object. 667 | 668 | :param args: Any positional arguments are passed on to the initializer 669 | of the :class:`property` class. 670 | :param kw: Any keyword arguments are passed on to the initializer of 671 | the :class:`property` class. 672 | 673 | Automatically calls :func:`inject_usage_notes()` during initialization 674 | (only if :data:`USAGE_NOTES_ENABLED` is :data:`True`). 675 | """ 676 | # It's not documented so I went to try it out and apparently the 677 | # property class initializer performs absolutely no argument 678 | # validation. The first argument doesn't have to be a callable, 679 | # in fact none of the arguments are even mandatory?! :-P 680 | super(custom_property, self).__init__(*args, **kw) 681 | # Explicit is better than implicit so I'll just go ahead and check 682 | # whether the value(s) given by the user make sense :-). 683 | self.ensure_callable('fget') 684 | # We only check the 'fset' and 'fdel' values when they are not None 685 | # because both of these arguments are supposed to be optional :-). 686 | for name in 'fset', 'fdel': 687 | if getattr(self, name) is not None: 688 | self.ensure_callable(name) 689 | # Copy some important magic members from the decorated method. 690 | for name in '__doc__', '__module__', '__name__': 691 | value = getattr(self.fget, name, None) 692 | if value is not None: 693 | setattr(self, name, value) 694 | # Inject usage notes when running under Sphinx. 695 | if USAGE_NOTES_ENABLED: 696 | self.inject_usage_notes() 697 | 698 | def ensure_callable(self, role): 699 | """ 700 | Ensure that a decorated value is in fact callable. 701 | 702 | :param role: The value's role (one of 'fget', 'fset' or 'fdel'). 703 | :raises: :exc:`exceptions.ValueError` when the value isn't callable. 704 | """ 705 | value = getattr(self, role) 706 | if not callable(value): 707 | msg = "Invalid '%s' value! (expected callable, got %r instead)" 708 | raise ValueError(msg % (role, value)) 709 | 710 | def inject_usage_notes(self): 711 | """ 712 | Inject the property's semantics into its documentation. 713 | 714 | Calls :func:`compose_usage_notes()` to get a description of the property's 715 | semantics and appends this to the property's documentation. If the 716 | property doesn't have documentation it will not be added. 717 | """ 718 | if self.usage_notes and self.__doc__ and isinstance(self.__doc__, basestring): 719 | notes = self.compose_usage_notes() 720 | if notes: 721 | self.__doc__ = "\n\n".join([ 722 | textwrap.dedent(self.__doc__), 723 | ".. note:: %s" % " ".join(notes), 724 | ]) 725 | 726 | def compose_usage_notes(self): 727 | """ 728 | Get a description of the property's semantics to include in its documentation. 729 | 730 | :returns: A list of strings describing the semantics of the 731 | :class:`custom_property` in reStructuredText_ format with 732 | Sphinx_ directives. 733 | 734 | .. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText 735 | .. _Sphinx: http://sphinx-doc.org/ 736 | """ 737 | template = DYNAMIC_PROPERTY_NOTE if self.dynamic else CUSTOM_PROPERTY_NOTE 738 | cls = custom_property if self.dynamic else self.__class__ 739 | dotted_path = "%s.%s" % (cls.__module__, cls.__name__) 740 | notes = [format(template, name=self.__name__, type=dotted_path)] 741 | if self.environment_variable: 742 | notes.append(format(ENVIRONMENT_PROPERTY_NOTE, variable=self.environment_variable)) 743 | if self.required: 744 | notes.append(format(REQUIRED_PROPERTY_NOTE, name=self.__name__)) 745 | if self.key: 746 | notes.append(KEY_PROPERTY_NOTE) 747 | if self.writable: 748 | notes.append(WRITABLE_PROPERTY_NOTE) 749 | if self.cached: 750 | notes.append(CACHED_PROPERTY_NOTE) 751 | if self.resettable: 752 | if self.cached: 753 | notes.append(RESETTABLE_CACHED_PROPERTY_NOTE) 754 | else: 755 | notes.append(RESETTABLE_WRITABLE_PROPERTY_NOTE) 756 | return notes 757 | 758 | def __get__(self, obj, type=None): 759 | """ 760 | Get the assigned, cached or computed value of the property. 761 | 762 | :param obj: The instance that owns the property. 763 | :param type: The class that owns the property. 764 | :returns: The value of the property. 765 | """ 766 | if obj is None: 767 | # Called to get the attribute of the class. 768 | return self 769 | else: 770 | # Called to get the attribute of an instance. We calculate the 771 | # property's dotted name here once to minimize string creation. 772 | dotted_name = format_property(obj, self.__name__) 773 | if self.key or self.writable or self.cached: 774 | # Check if a value has been assigned or cached. 775 | value = obj.__dict__.get(self.__name__, NOTHING) 776 | if value is not NOTHING: 777 | logger.spam("%s reporting assigned or cached value (%r) ..", dotted_name, value) 778 | return value 779 | # Check if the property has an environment variable. We do this 780 | # after checking for an assigned value so that the `writable' and 781 | # `environment_variable' options can be used together. 782 | if self.environment_variable: 783 | value = os.environ.get(self.environment_variable, NOTHING) 784 | if value is not NOTHING: 785 | logger.spam("%s reporting value from environment variable (%r) ..", dotted_name, value) 786 | return value 787 | # Compute the property's value. 788 | value = super(custom_property, self).__get__(obj, type) 789 | logger.spam("%s reporting computed value (%r) ..", dotted_name, value) 790 | if self.cached: 791 | # Cache the computed value. 792 | logger.spam("%s caching computed value ..", dotted_name) 793 | set_property(obj, self.__name__, value) 794 | return value 795 | 796 | def __set__(self, obj, value): 797 | """ 798 | Override the computed value of the property. 799 | 800 | :param obj: The instance that owns the property. 801 | :param value: The new value for the property. 802 | :raises: :exc:`~exceptions.AttributeError` if :attr:`writable` is 803 | :data:`False`. 804 | """ 805 | # Calculate the property's dotted name only once. 806 | dotted_name = format_property(obj, self.__name__) 807 | # Evaluate the property's setter (if any). 808 | try: 809 | logger.spam("%s calling setter with value %r ..", dotted_name, value) 810 | super(custom_property, self).__set__(obj, value) 811 | except AttributeError: 812 | logger.spam("%s setter raised attribute error, falling back.", dotted_name) 813 | if self.writable: 814 | # Override a computed or previously assigned value. 815 | logger.spam("%s overriding computed value to %r ..", dotted_name, value) 816 | set_property(obj, self.__name__, value) 817 | else: 818 | # Check if we're setting a key property during initialization. 819 | if self.key and obj.__dict__.get(self.__name__, None) is None: 820 | # Make sure we were given a hashable value. 821 | if not isinstance(value, Hashable): 822 | msg = "Invalid value for key property '%s'! (expected hashable object, got %r instead)" 823 | raise ValueError(msg % (self.__name__, value)) 824 | # Set the key property's value. 825 | logger.spam("%s setting initial value to %r ..", dotted_name, value) 826 | set_property(obj, self.__name__, value) 827 | else: 828 | # Refuse to override the computed value. 829 | msg = "%r object attribute %r is read-only" 830 | raise AttributeError(msg % (obj.__class__.__name__, self.__name__)) 831 | 832 | def __delete__(self, obj): 833 | """ 834 | Reset the assigned or cached value of the property. 835 | 836 | :param obj: The instance that owns the property. 837 | :raises: :exc:`~exceptions.AttributeError` if :attr:`resettable` is 838 | :data:`False`. 839 | 840 | Once the property has been deleted the next read will evaluate the 841 | decorated function to compute the value. 842 | """ 843 | # Calculate the property's dotted name only once. 844 | dotted_name = format_property(obj, self.__name__) 845 | # Evaluate the property's deleter (if any). 846 | try: 847 | logger.spam("%s calling deleter ..", dotted_name) 848 | super(custom_property, self).__delete__(obj) 849 | except AttributeError: 850 | logger.spam("%s deleter raised attribute error, falling back.", dotted_name) 851 | if self.resettable: 852 | # Reset the computed or overridden value. 853 | logger.spam("%s clearing assigned or computed value ..", dotted_name) 854 | clear_property(obj, self.__name__) 855 | else: 856 | msg = "%r object attribute %r is read-only" 857 | raise AttributeError(msg % (obj.__class__.__name__, self.__name__)) 858 | 859 | 860 | class writable_property(custom_property): 861 | 862 | """ 863 | A computed property that supports assignment. 864 | 865 | This is a variant of :class:`custom_property` 866 | that has the :attr:`~custom_property.writable` 867 | option enabled by default. 868 | """ 869 | 870 | writable = True 871 | 872 | 873 | class required_property(writable_property): 874 | 875 | """ 876 | A property that requires a value to be set. 877 | 878 | This is a variant of :class:`writable_property` that has the 879 | :attr:`~custom_property.required` option enabled by default. Refer to the 880 | documentation of the :attr:`~custom_property.required` option for an 881 | example. 882 | """ 883 | 884 | required = True 885 | 886 | 887 | class key_property(custom_property): 888 | 889 | """ 890 | A property whose value is used for comparison and hashing. 891 | 892 | This is a variant of :class:`custom_property` that has the 893 | :attr:`~custom_property.key` and :attr:`~custom_property.required` 894 | options enabled by default. 895 | """ 896 | 897 | key = True 898 | required = True 899 | 900 | 901 | class mutable_property(writable_property): 902 | 903 | """ 904 | A computed property that can be assigned and reset. 905 | 906 | This is a variant of :class:`writable_property` that 907 | has the :attr:`~custom_property.resettable` 908 | option enabled by default. 909 | """ 910 | 911 | resettable = True 912 | 913 | 914 | class lazy_property(custom_property): 915 | 916 | """ 917 | A computed property whose value is computed once and cached. 918 | 919 | This is a variant of :class:`custom_property` that 920 | has the :attr:`~custom_property.cached` 921 | option enabled by default. 922 | """ 923 | 924 | cached = True 925 | 926 | 927 | class cached_property(lazy_property): 928 | 929 | """ 930 | A computed property whose value is computed once and cached, but can be reset. 931 | 932 | This is a variant of :class:`lazy_property` that 933 | has the :attr:`~custom_property.resettable` 934 | option enabled by default. 935 | """ 936 | 937 | resettable = True 938 | -------------------------------------------------------------------------------- /property_manager/sphinx.py: -------------------------------------------------------------------------------- 1 | # Useful property variants for Python programming. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 2, 2020 5 | # URL: https://property-manager.readthedocs.io 6 | 7 | """ 8 | Integration with the Sphinx_ documentation generator. 9 | 10 | The :mod:`property_manager.sphinx` module uses the `Sphinx extension API`_ to 11 | customize the process of generating Sphinx based Python documentation. It 12 | modifies the documentation of :class:`.PropertyManager` subclasses to include 13 | an overview of superclasses, properties, public methods and special methods. It 14 | also includes hints about required properties and how the values of properties 15 | can be set by passing keyword arguments to the class initializer. 16 | 17 | For a simple example check out the documentation of the :class:`TypeInspector` 18 | class. Yes, that means this module is being used to document itself :-). 19 | 20 | The entry point to this module is the :func:`setup()` function. 21 | 22 | .. _Sphinx extension API: http://sphinx-doc.org/extdev/appapi.html 23 | """ 24 | 25 | # Standard library modules. 26 | import types 27 | 28 | # Modules included in our package. 29 | from property_manager import PropertyManager, custom_property, lazy_property, required_property 30 | from humanfriendly.tables import format_rst_table 31 | from humanfriendly.text import compact, concatenate, format 32 | 33 | # Public identifiers that require documentation. 34 | __all__ = ( 35 | 'setup', 36 | 'append_property_docs', 37 | 'TypeInspector', 38 | ) 39 | 40 | 41 | def setup(app): 42 | """ 43 | Make it possible to use :mod:`property_manager.sphinx` as a Sphinx extension. 44 | 45 | :param app: The Sphinx application object. 46 | 47 | To enable the use of this module you add the name of the module 48 | to the ``extensions`` option in your ``docs/conf.py`` script: 49 | 50 | .. code-block:: python 51 | 52 | extensions = [ 53 | 'sphinx.ext.autodoc', 54 | 'sphinx.ext.intersphinx', 55 | 'property_manager.sphinx', 56 | ] 57 | 58 | When Sphinx sees the :mod:`property_manager.sphinx` name it will import 59 | this module and call the :func:`setup()` function which will connect the 60 | :func:`append_property_docs()` function to ``autodoc-process-docstring`` 61 | events. 62 | """ 63 | app.connect('autodoc-process-docstring', append_property_docs) 64 | 65 | 66 | def append_property_docs(app, what, name, obj, options, lines): 67 | """ 68 | Render an overview with properties and methods of :class:`.PropertyManager` subclasses. 69 | 70 | This function implements a callback for ``autodoc-process-docstring`` that 71 | generates and appends an overview of member details to the docstrings of 72 | :class:`.PropertyManager` subclasses. 73 | 74 | The parameters expected by this function are those defined for Sphinx event 75 | callback functions (i.e. I'm not going to document them here :-). 76 | """ 77 | if is_suitable_type(obj): 78 | paragraphs = [] 79 | details = TypeInspector(type=obj) 80 | paragraphs.append(format("Here's an overview of the :class:`%s` class:", obj.__name__)) 81 | # Whitespace in labels is replaced with non breaking spaces to disable wrapping of the label text. 82 | data = [(format("%s:", label.replace(' ', u'\u00A0')), text) for label, text in details.overview if text] 83 | paragraphs.append(format_rst_table(data)) 84 | # Append any hints after the overview. 85 | hints = (details.required_hint, details.initializer_hint) 86 | if any(hints): 87 | paragraphs.append(' '.join(h for h in hints if h)) 88 | # Insert padding between the regular docstring and generated content. 89 | if lines: 90 | lines.append('') 91 | lines.extend('\n\n'.join(paragraphs).splitlines()) 92 | 93 | 94 | def is_suitable_type(obj): 95 | try: 96 | return issubclass(obj, PropertyManager) 97 | except Exception: 98 | return False 99 | 100 | 101 | class TypeInspector(PropertyManager): 102 | 103 | """Introspection of :class:`.PropertyManager` subclasses.""" 104 | 105 | @lazy_property 106 | def custom_properties(self): 107 | """A list of tuples with the names and values of custom properties.""" 108 | return [(n, v) for n, v in self.properties if isinstance(v, custom_property)] 109 | 110 | @lazy_property 111 | def initializer_hint(self): 112 | """A hint that properties can be set using keyword arguments to the initializer (a string or :data:`None`).""" 113 | names = sorted( 114 | name for name, value in self.custom_properties 115 | if value.key or value.required or value.writable 116 | ) 117 | if names: 118 | return compact( 119 | """ 120 | You can set the {values} of the {names} {properties} 121 | by passing {arguments} to the class initializer. 122 | """, 123 | names=self.format_properties(names), 124 | values=("value" if len(names) == 1 else "values"), 125 | properties=("property" if len(names) == 1 else "properties"), 126 | arguments=("a keyword argument" if len(names) == 1 else "keyword arguments"), 127 | ) 128 | 129 | @lazy_property 130 | def members(self): 131 | """An iterable of tuples with the names and values of the non-inherited members of :class:`type`.""" 132 | return list(self.type.__dict__.items()) 133 | 134 | @lazy_property 135 | def methods(self): 136 | """An iterable of method names of :class:`type`.""" 137 | return sorted(n for n, v in self.members if isinstance(v, types.FunctionType)) 138 | 139 | @lazy_property 140 | def overview(self): 141 | """Render an overview with related members grouped together.""" 142 | return ( 143 | ("Superclass" if len(self.type.__bases__) == 1 else "Superclasses", 144 | concatenate(format(":class:`~%s.%s`", b.__module__, b.__name__) for b in self.type.__bases__)), 145 | ("Special methods", self.format_methods(self.special_methods)), 146 | ("Public methods", self.format_methods(self.public_methods)), 147 | ("Properties", self.format_properties(n for n, v in self.properties)), 148 | ) 149 | 150 | @lazy_property 151 | def properties(self): 152 | """An iterable of tuples with property names (strings) and values (:class:`property` objects).""" 153 | return [(n, v) for n, v in self.members if isinstance(v, property)] 154 | 155 | @lazy_property 156 | def public_methods(self): 157 | """An iterable of strings with the names of public methods (that don't start with an underscore).""" 158 | return sorted(n for n in self.methods if not n.startswith('_')) 159 | 160 | @lazy_property 161 | def required_hint(self): 162 | """A hint about required properties (a string or :data:`None`).""" 163 | names = sorted(name for name, value in self.custom_properties if value.required) 164 | if names: 165 | return compact( 166 | """ 167 | When you initialize a :class:`{type}` object you are required 168 | to provide {values} for the {required} {properties}. 169 | """, 170 | type=self.type.__name__, 171 | required=self.format_properties(names), 172 | values=("a value" if len(names) == 1 else "values"), 173 | properties=("property" if len(names) == 1 else "properties"), 174 | ) 175 | 176 | @lazy_property 177 | def special_methods(self): 178 | """An iterable of strings with the names of special methods (surrounded in double underscores).""" 179 | methods = sorted(name for name in self.methods if name.startswith('__') and name.endswith('__')) 180 | if '__init__' in methods: 181 | methods.remove('__init__') 182 | methods.insert(0, '__init__') 183 | return methods 184 | 185 | @required_property 186 | def type(self): 187 | """A subclass of :class:`.PropertyManager`.""" 188 | 189 | def format_methods(self, names): 190 | """Format a list of method names as reStructuredText.""" 191 | return concatenate(format(":func:`%s()`", n) for n in sorted(names)) 192 | 193 | def format_properties(self, names): 194 | """Format a list of property names as reStructuredText.""" 195 | return concatenate(format(":attr:`%s`", n) for n in sorted(names)) 196 | -------------------------------------------------------------------------------- /property_manager/tests.py: -------------------------------------------------------------------------------- 1 | # Tests of custom properties for Python programming. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: March 2, 2020 5 | # URL: https://property-manager.readthedocs.io 6 | 7 | """Automated tests for the :mod:`property_manager` module.""" 8 | 9 | # Standard library modules. 10 | import logging 11 | import os 12 | import random 13 | import sys 14 | import unittest 15 | 16 | # External dependencies. 17 | import coloredlogs 18 | from humanfriendly.text import compact, format 19 | from verboselogs import VerboseLogger 20 | 21 | # Modules included in our package. 22 | import property_manager 23 | from property_manager import ( 24 | CACHED_PROPERTY_NOTE, 25 | CUSTOM_PROPERTY_NOTE, 26 | DYNAMIC_PROPERTY_NOTE, 27 | ENVIRONMENT_PROPERTY_NOTE, 28 | REQUIRED_PROPERTY_NOTE, 29 | RESETTABLE_CACHED_PROPERTY_NOTE, 30 | RESETTABLE_WRITABLE_PROPERTY_NOTE, 31 | WRITABLE_PROPERTY_NOTE, 32 | PropertyManager, 33 | cached_property, 34 | custom_property, 35 | key_property, 36 | lazy_property, 37 | mutable_property, 38 | required_property, 39 | writable_property, 40 | ) 41 | from property_manager.sphinx import TypeInspector, setup, append_property_docs 42 | 43 | # Initialize a logger for this module. 44 | logger = VerboseLogger(__name__) 45 | 46 | 47 | class PropertyManagerTestCase(unittest.TestCase): 48 | 49 | """Container for the :mod:`property_manager` test suite.""" 50 | 51 | def setUp(self): 52 | """Enable verbose logging and usage notes.""" 53 | property_manager.USAGE_NOTES_ENABLED = True 54 | coloredlogs.install(level=logging.NOTSET) 55 | # Separate the name of the test method (printed by the superclass 56 | # and/or py.test without a newline at the end) from the first line of 57 | # logging output that the test method is likely going to generate. 58 | sys.stderr.write("\n") 59 | 60 | def test_builtin_property(self): 61 | """ 62 | Test that our assumptions about the behavior of :class:`property` are correct. 63 | 64 | This test helps to confirm that :class:`PropertyInspector` (on which 65 | other tests are based) shows sane behavior regardless of whether a 66 | custom property is inspected. 67 | """ 68 | class NormalPropertyTest(object): 69 | @property 70 | def normal_test_property(self): 71 | return random.random() 72 | with PropertyInspector(NormalPropertyTest, 'normal_test_property') as p: 73 | assert p.is_recognizable 74 | assert p.is_recomputed 75 | assert p.is_read_only 76 | assert not p.is_injectable 77 | 78 | def test_custom_property(self): 79 | """Test that :class:`.custom_property` works just like :class:`property`.""" 80 | class CustomPropertyTest(object): 81 | @custom_property 82 | def custom_test_property(self): 83 | return random.random() 84 | with PropertyInspector(CustomPropertyTest, 'custom_test_property') as p: 85 | assert p.is_recognizable 86 | assert p.is_recomputed 87 | assert p.is_read_only 88 | assert not p.is_injectable 89 | p.check_usage_notes() 90 | # Test that custom properties expect a function argument and validate their assumption. 91 | self.assertRaises(ValueError, custom_property, None) 92 | 93 | def test_writable_property(self): 94 | """Test that :class:`.writable_property` supports assignment.""" 95 | class WritablePropertyTest(object): 96 | @writable_property 97 | def writable_test_property(self): 98 | return random.random() 99 | with PropertyInspector(WritablePropertyTest, 'writable_test_property') as p: 100 | assert p.is_recognizable 101 | assert p.is_recomputed 102 | assert p.is_writable 103 | assert not p.is_resettable 104 | assert p.is_injectable 105 | p.check_usage_notes() 106 | 107 | def test_required_property(self): 108 | """Test that :class:`.required_property` performs validation.""" 109 | class RequiredPropertyTest(PropertyManager): 110 | @required_property 111 | def required_test_property(self): 112 | pass 113 | with PropertyInspector(RequiredPropertyTest, 'required_test_property', required_test_property=42) as p: 114 | assert p.is_recognizable 115 | assert p.is_writable 116 | assert not p.is_resettable 117 | assert p.is_injectable 118 | p.check_usage_notes() 119 | # Test that required properties must be set using the constructor. 120 | self.assertRaises(TypeError, RequiredPropertyTest) 121 | 122 | def test_mutable_property(self): 123 | """Test that :class:`mutable_property` supports assignment and deletion.""" 124 | class MutablePropertyTest(object): 125 | @mutable_property 126 | def mutable_test_property(self): 127 | return random.random() 128 | with PropertyInspector(MutablePropertyTest, 'mutable_test_property') as p: 129 | assert p.is_recognizable 130 | assert p.is_recomputed 131 | assert p.is_writable 132 | assert p.is_resettable 133 | assert p.is_injectable 134 | p.check_usage_notes() 135 | 136 | def test_lazy_property(self): 137 | """Test that :class:`lazy_property` caches computed values.""" 138 | class LazyPropertyTest(object): 139 | @lazy_property 140 | def lazy_test_property(self): 141 | return random.random() 142 | with PropertyInspector(LazyPropertyTest, 'lazy_test_property') as p: 143 | assert p.is_recognizable 144 | assert p.is_cached 145 | assert p.is_read_only 146 | p.check_usage_notes() 147 | 148 | def test_cached_property(self): 149 | """Test that :class:`.cached_property` caches its result.""" 150 | class CachedPropertyTest(object): 151 | @cached_property 152 | def cached_test_property(self): 153 | return random.random() 154 | with PropertyInspector(CachedPropertyTest, 'cached_test_property') as p: 155 | assert p.is_recognizable 156 | assert p.is_cached 157 | assert not p.is_writable 158 | assert p.is_resettable 159 | p.check_usage_notes() 160 | 161 | def test_environment_property(self): 162 | """Test that custom properties can be based on environment variables.""" 163 | variable_name = 'PROPERTY_MANAGER_TEST_VALUE' 164 | 165 | class EnvironmentPropertyTest(object): 166 | @mutable_property(environment_variable=variable_name) 167 | def environment_test_property(self): 168 | return str(random.random()) 169 | 170 | with PropertyInspector(EnvironmentPropertyTest, 'environment_test_property') as p: 171 | # Make sure the property's value can be overridden using the 172 | # expected environment variable. 173 | value_from_environment = str(random.random()) 174 | os.environ[variable_name] = value_from_environment 175 | assert p.value == value_from_environment 176 | # Make sure assignment overrides the value from the environment. 177 | value_from_assignment = str(random.random()) 178 | assert value_from_assignment != value_from_environment 179 | p.value = value_from_assignment 180 | assert p.value == value_from_assignment 181 | # Make sure the assigned value can be cleared so that the 182 | # property's value falls back to the environment variable. 183 | p.delete() 184 | assert p.value == value_from_environment 185 | # Make sure the property's value falls back to the computed value 186 | # if the environment variable isn't set. 187 | os.environ.pop(variable_name) 188 | assert p.value != value_from_assignment 189 | assert p.value != value_from_environment 190 | p.check_usage_notes() 191 | 192 | def test_property_manager_repr(self): 193 | """Test :func:`repr()` rendering of :class:`PropertyManager` objects.""" 194 | class RepresentationTest(PropertyManager): 195 | @required_property 196 | def important(self): 197 | pass 198 | 199 | @mutable_property 200 | def optional(self): 201 | return 42 202 | instance = RepresentationTest(important=1) 203 | assert "important=1" in repr(instance) 204 | assert "optional=42" in repr(instance) 205 | 206 | def test_property_injection(self): 207 | """Test that :class:`.PropertyManager` raises an error for unknown properties.""" 208 | class PropertyInjectionTest(PropertyManager): 209 | @mutable_property 210 | def injected_test_property(self): 211 | return 'default' 212 | assert PropertyInjectionTest().injected_test_property == 'default' 213 | assert PropertyInjectionTest(injected_test_property='injected').injected_test_property == 'injected' 214 | self.assertRaises(TypeError, PropertyInjectionTest, random_keyword_argument=True) 215 | 216 | def test_property_customization(self): 217 | """Test that :func:`.custom_property.__new__()` dynamically constructs subclasses.""" 218 | class CustomizedPropertyTest(object): 219 | @custom_property(cached=True, writable=True) 220 | def customized_test_property(self): 221 | pass 222 | with PropertyInspector(CustomizedPropertyTest, 'customized_test_property') as p: 223 | assert p.is_recognizable 224 | assert p.is_cached 225 | assert p.is_writable 226 | 227 | def test_setters(self): 228 | """Test that custom properties support setters.""" 229 | class SetterTest(object): 230 | 231 | @custom_property 232 | def setter_test_property(self): 233 | return getattr(self, 'whatever_you_want_goes_here', 42) 234 | 235 | @setter_test_property.setter 236 | def setter_test_property(self, value): 237 | if value < 0: 238 | raise ValueError 239 | self.whatever_you_want_goes_here = value 240 | 241 | with PropertyInspector(SetterTest, 'setter_test_property') as p: 242 | # This is basically just testing the lazy property. 243 | assert p.is_recognizable 244 | assert p.value == 42 245 | # Test that the setter is being called by verifying 246 | # that it raises a value error on invalid arguments. 247 | self.assertRaises(ValueError, setattr, p, 'value', -5) 248 | # Test that valid values are actually set. 249 | p.value = 13 250 | assert p.value == 13 251 | 252 | def test_deleters(self): 253 | """Test that custom properties support deleters.""" 254 | class DeleterTest(object): 255 | 256 | @custom_property 257 | def deleter_test_property(self): 258 | return getattr(self, 'whatever_you_want_goes_here', 42) 259 | 260 | @deleter_test_property.setter 261 | def deleter_test_property(self, value): 262 | self.whatever_you_want_goes_here = value 263 | 264 | @deleter_test_property.deleter 265 | def deleter_test_property(self): 266 | delattr(self, 'whatever_you_want_goes_here') 267 | 268 | with PropertyInspector(DeleterTest, 'deleter_test_property') as p: 269 | # This is basically just testing the custom property. 270 | assert p.is_recognizable 271 | assert p.value == 42 272 | # Make sure we can set a new value. 273 | p.value = 13 274 | assert p.value == 13 275 | # Make sure we can delete the value. 276 | p.delete() 277 | # Here we expect the computed value. 278 | assert p.value == 42 279 | 280 | def test_cache_invalidation(self): 281 | """Test that :func:`.PropertyManager.clear_cached_properties()` correctly clears cached property values.""" 282 | class CacheInvalidationTest(PropertyManager): 283 | 284 | def __init__(self, counter): 285 | self.counter = counter 286 | 287 | @lazy_property 288 | def lazy(self): 289 | return self.counter * 2 290 | 291 | @cached_property 292 | def cached(self): 293 | return self.counter * 2 294 | 295 | instance = CacheInvalidationTest(42) 296 | # Test that the lazy property was calculated based on the input. 297 | assert instance.lazy == (42 * 2) 298 | # Test that the cached property was calculated based on the input. 299 | assert instance.cached == (42 * 2) 300 | # Invalidate the values of cached properties. 301 | instance.counter *= 2 302 | instance.clear_cached_properties() 303 | # Make sure the value of the lazy property *wasn't* cleared. 304 | assert instance.lazy == (42 * 2) 305 | # Make sure the value of the cached property *was* cleared. 306 | assert instance.cached == (42 * 2 * 2) 307 | 308 | def test_key_properties(self): 309 | """Test that :attr:`.PropertyManager.key_properties` reports only properties defined by subclasses.""" 310 | class KeyPropertiesTest(PropertyManager): 311 | 312 | @key_property 313 | def one(self): 314 | return 1 315 | 316 | @key_property 317 | def two(self): 318 | return 2 319 | 320 | instance = KeyPropertiesTest() 321 | assert list(instance.key_properties) == ['one', 'two'] 322 | assert instance.key_values == (('one', 1), ('two', 2)) 323 | 324 | def test_hashable_objects(self): 325 | """Test that :attr:`.PropertyManager.__hash__` works properly.""" 326 | class HashableObject(PropertyManager): 327 | 328 | @key_property 329 | def a(self): 330 | return 1 331 | 332 | @key_property 333 | def b(self): 334 | return 2 335 | 336 | # Create a set and put an object in it. 337 | collection = set() 338 | collection.add(HashableObject()) 339 | assert len(collection) == 1 340 | # Add a second (identical) object (or not :-). 341 | collection.add(HashableObject()) 342 | assert len(collection) == 1 343 | # Add a third (non-identical) object. 344 | collection.add(HashableObject(b=42)) 345 | assert len(collection) == 2 346 | # Try to add an object with an unhashable property value. 347 | self.assertRaises(ValueError, HashableObject, b=[]) 348 | 349 | def test_sortable_objects(self): 350 | """Test that the rich comparison methods work properly.""" 351 | class SortableObject(PropertyManager): 352 | 353 | @key_property 354 | def a(self): 355 | return 1 356 | 357 | @key_property 358 | def b(self): 359 | return 2 360 | 361 | # Test the non-equality operator. 362 | assert SortableObject(a=1, b=2) != SortableObject(a=2, b=2) 363 | # Test the "less than" operator. 364 | assert SortableObject(a=1, b=2) < SortableObject(a=2, b=1) 365 | assert not SortableObject(a=2, b=1) < SortableObject(a=1, b=2) 366 | # Test the "less than or equal" operator. 367 | assert SortableObject(a=1, b=2) <= SortableObject(a=2, b=1) 368 | assert SortableObject(a=1, b=2) <= SortableObject(a=1, b=2) 369 | assert not SortableObject(a=2, b=1) <= SortableObject(a=1, b=2) 370 | # Test the "greater than" operator. 371 | assert SortableObject(a=2, b=1) > SortableObject(a=1, b=2) 372 | assert not SortableObject(a=1, b=2) > SortableObject(a=2, b=1) 373 | # Test the "greater than or equal" operator. 374 | assert SortableObject(a=2, b=1) >= SortableObject(a=1, b=2) 375 | assert SortableObject(a=2, b=1) >= SortableObject(a=2, b=1) 376 | assert not SortableObject(a=1, b=2) >= SortableObject(a=2, b=1) 377 | # Test comparison with arbitrary objects. This should not raise any 378 | # unexpected exceptions. 379 | instance = SortableObject() 380 | arbitrary_object = object() 381 | if sys.version_info[0] <= 2: 382 | # In Python 2 arbitrary objects "supported" rich comparison. 383 | assert instance >= arbitrary_object or instance <= arbitrary_object 384 | else: 385 | # Since Python 3 it raises a TypeError instead. 386 | self.assertRaises(TypeError, lambda: instance >= arbitrary_object or instance <= arbitrary_object) 387 | 388 | def test_sphinx_integration(self): 389 | """Tests for the :mod:`property_manager.sphinx` module.""" 390 | class FakeApp(object): 391 | 392 | def __init__(self): 393 | self.callbacks = {} 394 | 395 | def connect(self, event, callback): 396 | self.callbacks.setdefault(event, []).append(callback) 397 | 398 | app = FakeApp() 399 | setup(app) 400 | assert append_property_docs in app.callbacks['autodoc-process-docstring'] 401 | lines = ["Some boring description."] 402 | obj = TypeInspector 403 | append_property_docs(app=app, what=None, name=None, obj=obj, options=None, lines=lines) 404 | assert len(lines) > 0 405 | assert lines[0] == "Some boring description." 406 | assert not lines[1] 407 | assert lines[2] == "Here's an overview of the :class:`TypeInspector` class:" 408 | assert not lines[3] 409 | assert lines[-1] == compact(""" 410 | When you initialize a :class:`TypeInspector` object you are 411 | required to provide a value for the :attr:`type` property. You can 412 | set the value of the :attr:`type` property by passing a keyword 413 | argument to the class initializer. 414 | """) 415 | 416 | def test_init_sorting(self): 417 | """Make sure __init__() is sorted before other special methods.""" 418 | inspector = TypeInspector(type=PropertyInspector) 419 | assert inspector.special_methods[0] == '__init__' 420 | 421 | 422 | class PropertyInspector(object): 423 | 424 | """Introspecting properties with properties (turtles all the way down).""" 425 | 426 | def __init__(self, owner, name, *args, **kw): 427 | """ 428 | Initialize a :class:`PropertyInspector` object. 429 | 430 | :param owner: The class that owns the property. 431 | :param name: The name of the property (a string). 432 | :param args: Any positional arguments needed to initialize an instance 433 | of the owner class. 434 | :param kw: Any keyword arguments needed to initialize an instance of 435 | the owner class. 436 | """ 437 | self.owner_object = owner(*args, **kw) 438 | self.owner_type = owner 439 | self.property_name = name 440 | self.property_object = getattr(owner, name) 441 | self.property_type = self.property_object.__class__ 442 | 443 | def __enter__(self): 444 | """Enable the syntax of context managers.""" 445 | return self 446 | 447 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 448 | """Enable the syntax of context managers.""" 449 | pass 450 | 451 | @property 452 | def value(self): 453 | """Get the value of the property from the owner's instance.""" 454 | return getattr(self.owner_object, self.property_name) 455 | 456 | @value.setter 457 | def value(self, new_value): 458 | """Set the value of the property on the owner's instance.""" 459 | setattr(self.owner_object, self.property_name, new_value) 460 | 461 | def delete(self): 462 | """Delete the (value of the) property.""" 463 | delattr(self.owner_object, self.property_name) 464 | 465 | @property 466 | def is_recognizable(self): 467 | """ 468 | :data:`True` if the property can be easily recognized, :data:`False` otherwise. 469 | 470 | This function confirms that custom properties subclass Python's built 471 | in :class:`property` class so that introspection of class members using 472 | :func:`isinstance()` correctly recognizes properties as such, even for 473 | code which is otherwise unaware of the custom properties defined by the 474 | :mod:`property_manager` module. 475 | """ 476 | return isinstance(self.property_object, property) 477 | 478 | @property 479 | def is_recomputed(self): 480 | """:data:`True` if the property is recomputed each time, :data:`False` otherwise.""" 481 | return not self.is_cached 482 | 483 | @property 484 | def is_cached(self): 485 | """:data:`True` if the property is cached (not recomputed), :data:`False` otherwise.""" 486 | class CachedPropertyTest(object): 487 | @self.property_type 488 | def value(self): 489 | return random.random() 490 | obj = CachedPropertyTest() 491 | return (obj.value == obj.value) 492 | 493 | @property 494 | def is_read_only(self): 495 | """:data:`True` if the property is read only, :data:`False` otherwise.""" 496 | return not self.is_writable and not self.is_resettable 497 | 498 | @property 499 | def is_writable(self): 500 | """:data:`True` if the property supports assignment, :data:`False` otherwise.""" 501 | unique_value = object() 502 | try: 503 | setattr(self.owner_object, self.property_name, unique_value) 504 | return getattr(self.owner_object, self.property_name) is unique_value 505 | except AttributeError: 506 | return False 507 | 508 | @property 509 | def is_resettable(self): 510 | """:data:`True` if the property can be reset to its computed value, :data:`False` otherwise.""" 511 | try: 512 | delattr(self.owner_object, self.property_name) 513 | return True 514 | except AttributeError: 515 | return False 516 | 517 | @property 518 | def is_injectable(self): 519 | """:data:`True` if the property can be set via the owner's constructor, :data:`False` otherwise.""" 520 | initial_value = object() 521 | injected_value = object() 522 | try: 523 | class PropertyOwner(PropertyManager): 524 | @self.property_type 525 | def test_property(self): 526 | return initial_value 527 | clean_instance = PropertyOwner() 528 | injected_instance = PropertyOwner(test_property=injected_value) 529 | return clean_instance.test_property is initial_value and injected_instance.test_property is injected_value 530 | except AttributeError: 531 | return False 532 | 533 | def check_usage_notes(self): 534 | """Check whether the correct notes are embedded in the documentation.""" 535 | class DocumentationTest(object): 536 | @self.property_type 537 | def documented_property(self): 538 | """Documentation written by the author.""" 539 | return random.random() 540 | documentation = DocumentationTest.documented_property.__doc__ 541 | # Test that the sentence added for custom properties is always present. 542 | cls = custom_property if self.property_type.dynamic else self.property_type 543 | custom_property_note = format( 544 | DYNAMIC_PROPERTY_NOTE if self.property_type.dynamic else CUSTOM_PROPERTY_NOTE, 545 | name='documented_property', type="%s.%s" % (cls.__module__, cls.__name__), 546 | ) 547 | if DocumentationTest.documented_property.usage_notes: 548 | assert custom_property_note in documentation 549 | else: 550 | assert custom_property_note not in documentation 551 | # If CUSTOM_PROPERTY_NOTE is not present we assume that none of the 552 | # other usage notes will be present either. 553 | return 554 | # Test that the sentence added for writable properties is present when applicable. 555 | assert self.property_type.writable == (WRITABLE_PROPERTY_NOTE in documentation) 556 | # Test that the sentence added for cached properties is present when applicable. 557 | assert self.property_type.cached == (CACHED_PROPERTY_NOTE in documentation) 558 | # Test that the sentence added for resettable properties is present when applicable. 559 | if self.is_resettable: 560 | assert self.is_cached == (RESETTABLE_CACHED_PROPERTY_NOTE in documentation) 561 | assert self.is_writable == (RESETTABLE_WRITABLE_PROPERTY_NOTE in documentation) 562 | else: 563 | assert RESETTABLE_CACHED_PROPERTY_NOTE not in documentation 564 | assert RESETTABLE_WRITABLE_PROPERTY_NOTE not in documentation 565 | # Test that the sentence added for required properties is present when applicable. 566 | required_property_note = format(REQUIRED_PROPERTY_NOTE, name='documented_property') 567 | assert self.property_type.required == (required_property_note in documentation) 568 | # Test that the sentence added for environment properties is present when applicable. 569 | environment_note = format(ENVIRONMENT_PROPERTY_NOTE, variable=self.property_type.environment_variable) 570 | assert bool(self.property_type.environment_variable) == (environment_note in documentation) 571 | -------------------------------------------------------------------------------- /requirements-checks.txt: -------------------------------------------------------------------------------- 1 | # Python packages required to run `make check'. 2 | flake8 >= 2.6.0 3 | flake8-docstrings >= 0.2.8 4 | pyflakes >= 1.2.3 5 | -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | coloredlogs >= 5.0 2 | pytest >= 2.6.1 3 | pytest-cov >= 2.2.1 4 | -------------------------------------------------------------------------------- /requirements-travis.txt: -------------------------------------------------------------------------------- 1 | --requirement=requirements-checks.txt 2 | --requirement=requirements-tests.txt 3 | --requirement=requirements.txt 4 | coveralls 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | humanfriendly >= 8.0 2 | verboselogs >= 1.1 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Enable universal wheels because this package is 2 | # pure Python and works on Python 2 and 3 alike. 3 | 4 | [wheel] 5 | universal=1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Setup script for the property-manager package. 4 | # 5 | # Author: Peter Odding 6 | # Last Change: March 2, 2020 7 | # URL: https://property-manager.readthedocs.io 8 | 9 | """ 10 | Setup script for the `property-manager` package. 11 | 12 | **python setup.py install** 13 | Install from the working directory into the current Python environment. 14 | 15 | **python setup.py sdist** 16 | Build a source distribution archive. 17 | 18 | **python setup.py bdist_wheel** 19 | Build a wheel distribution archive. 20 | """ 21 | 22 | # Standard library modules. 23 | import codecs 24 | import os 25 | import re 26 | 27 | # De-facto standard solution for Python packaging. 28 | from setuptools import find_packages, setup 29 | 30 | 31 | def get_contents(*args): 32 | """Get the contents of a file relative to the source distribution directory.""" 33 | with codecs.open(get_absolute_path(*args), 'r', 'UTF-8') as handle: 34 | return handle.read() 35 | 36 | 37 | def get_version(*args): 38 | """Extract the version number from a Python module.""" 39 | contents = get_contents(*args) 40 | metadata = dict(re.findall('__([a-z]+)__ = [\'"]([^\'"]+)', contents)) 41 | return metadata['version'] 42 | 43 | 44 | def get_requirements(*args): 45 | """Get requirements from pip requirement files.""" 46 | requirements = set() 47 | with open(get_absolute_path(*args)) as handle: 48 | for line in handle: 49 | # Strip comments. 50 | line = re.sub(r'^#.*|\s#.*', '', line) 51 | # Ignore empty lines 52 | if line and not line.isspace(): 53 | requirements.add(re.sub(r'\s+', '', line)) 54 | return sorted(requirements) 55 | 56 | 57 | def get_absolute_path(*args): 58 | """Transform relative pathnames into absolute pathnames.""" 59 | return os.path.join(os.path.dirname(os.path.abspath(__file__)), *args) 60 | 61 | 62 | setup(name="property-manager", 63 | version=get_version('property_manager', '__init__.py'), 64 | description=("Useful property variants for Python programming (required" 65 | " properties, writable properties, cached properties, etc)"), 66 | long_description=get_contents('README.rst'), 67 | url='https://property-manager.readthedocs.io', 68 | author="Peter Odding", 69 | author_email='peter@peterodding.com', 70 | license='MIT', 71 | packages=find_packages(), 72 | install_requires=get_requirements('requirements.txt'), 73 | tests_require=get_requirements('requirements-tests.txt'), 74 | python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', 75 | classifiers=[ 76 | 'Development Status :: 5 - Production/Stable', 77 | 'Intended Audience :: Developers', 78 | 'Intended Audience :: Information Technology', 79 | 'Intended Audience :: System Administrators', 80 | 'License :: OSI Approved :: MIT License', 81 | 'Natural Language :: English', 82 | 'Operating System :: OS Independent', 83 | 'Programming Language :: Python', 84 | 'Programming Language :: Python :: 2', 85 | 'Programming Language :: Python :: 2.7', 86 | 'Programming Language :: Python :: 3', 87 | 'Programming Language :: Python :: 3.5', 88 | 'Programming Language :: Python :: 3.6', 89 | 'Programming Language :: Python :: 3.7', 90 | 'Programming Language :: Python :: 3.8', 91 | 'Programming Language :: Python :: Implementation :: CPython', 92 | 'Programming Language :: Python :: Implementation :: PyPy', 93 | 'Topic :: Documentation :: Sphinx', 94 | 'Topic :: Software Development', 95 | 'Topic :: Software Development :: Documentation', 96 | 'Topic :: Software Development :: Libraries', 97 | 'Topic :: Software Development :: Libraries :: Python Modules', 98 | ]) 99 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py35, py36, py37, py38, pypy 3 | 4 | [testenv] 5 | deps = -rrequirements-tests.txt 6 | commands = py.test {posargs} 7 | 8 | [pytest] 9 | addopts = --verbose 10 | python_files = property_manager/tests.py 11 | 12 | [flake8] 13 | exclude = .tox 14 | extend-ignore = D211,D301,D401 15 | max-line-length = 120 16 | --------------------------------------------------------------------------------