├── .editorconfig ├── .gitignore ├── .readthedocs.yml ├── .travis.yml ├── .vscode └── settings.json ├── CHANGELOG.rst ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── modules.rst ├── pydecor.caches.rst ├── pydecor.constants.rst ├── pydecor.decorators.generic.rst ├── pydecor.decorators.ready_to_wear.rst ├── pydecor.decorators.rst ├── pydecor.functions.rst ├── pydecor.rst └── requirements.txt ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── scripts └── check_ready_to_distribute.py ├── setup.cfg ├── setup.py ├── src └── pydecor │ ├── __init__.py │ ├── _memoization.py │ ├── _version.py │ ├── caches.py │ ├── constants.py │ ├── decorators │ ├── __init__.py │ ├── _utility.py │ ├── _visibility.py │ ├── generic.py │ └── ready_to_wear.py │ └── functions.py ├── tests ├── __init__.py ├── decorators │ ├── __init__.py │ ├── exports │ │ ├── __init__.py │ │ ├── class_export.py │ │ ├── list_all.py │ │ ├── multi_export.py │ │ ├── no_all.py │ │ └── tuple_all.py │ ├── test_export.py │ ├── test_generics.py │ └── test_ready_to_wear.py ├── test_caches.py ├── test_functions.py └── test_memoization.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | 16 | [*.{js,html,css,scss,json,ini,yml,yaml}] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.rst] 21 | indent_style = space 22 | indent_size = 3 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Caches 7 | .pytest_cache/ 8 | .mypy_cache/ 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | !docs/build 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | pip-wheel-metadata/ 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *,cover 52 | .hypothesis/ 53 | .pytest.xml 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # IPython Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | .idea 99 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | submodules: 4 | include: [] 5 | 6 | build: 7 | image: latest 8 | 9 | python: 10 | version: 3.7 11 | install: 12 | - requirements: docs/requirements.txt 13 | 14 | sphinx: 15 | builder: html 16 | configuration: docs/conf.py 17 | 18 | formats: 19 | - pdf 20 | # - htmlzip 21 | # - epub 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Travis file for CI testing 2 | --- 3 | language: python 4 | 5 | python: 6 | - "3.6" 7 | - "3.7" 8 | - "3.8" 9 | - "pypy3" 10 | 11 | script: make test 12 | 13 | jobs: 14 | include: 15 | - stage: lint 16 | script: make lint 17 | python: "3.8" 18 | - stage: deploy 19 | script: skip 20 | python: "3.8" 21 | deploy: 22 | provider: pypi 23 | user: "__token__" 24 | password: $PYPI_TOKEN 25 | distributions: "sdist bdist_wheel" 26 | skip_existing: true 27 | on: 28 | tags: true 29 | 30 | 31 | stages: 32 | - lint 33 | - test 34 | - deploy 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "markdown.extension.toc.githubCompatibility": true, 4 | 5 | "python.formatting.provider": "black", 6 | "python.formatting.blackArgs": ["--line-length", "80"], 7 | 8 | "python.linting.flake8Enabled": true, 9 | "python.linting.flake8Args": [], 10 | "python.linting.pydocstyleEnabled": true, 11 | "python.linting.pydocstyleArgs": [], 12 | "python.linting.pylintEnabled": true, 13 | "python.linting.pylintArgs": [], 14 | "python.linting.mypyEnabled": true, 15 | "python.linting.mypyArgs": [], 16 | 17 | "python.pythonPath": "venv/bin/python", 18 | "restructuredtext.confPath": "" 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 2.0.1 5 | ----- 6 | 7 | * Fix regression in `@intercept` decorator due to changes in how generic 8 | decorators pass arguments in 2.0.0 9 | 10 | 2.0.0 11 | ----- 12 | 13 | * ``export`` - register entities in a module's `__all__` list 14 | (thanks @Paebbels!) 15 | * Use of ``Decorator`` class and consistent callable signature for generic 16 | decorators is now required 17 | * Drop support for Python <3.6 18 | * Move to a `src/` layout 19 | * Lots of clarifications, typo fixes, and improvements to the docs 20 | * Lots of development environment improvements: 21 | * Automatic distribution of tagged commits via PyPI (thanks @Paebbels!) 22 | * ``Makefile`` for a consistent interface into build operations 23 | * Improvements to ``tox`` configuration 24 | * Addition of consistent and required linting with pylint, mypy, and flake8 25 | * Autoformatting with ``black`` 26 | 27 | 1.1.3 28 | ----- 29 | 30 | Apparently `pythonhosted.org` has been deprecated, so I set up a 31 | Read the Docs account and moved the documentation there. 32 | 33 | * Uploaded README to point to new docs 34 | * Added docs build image for funsies. 35 | 36 | 1.1.2 37 | ----- 38 | 39 | * fixed an issue with the README 40 | 41 | 1.1.1 42 | ----- 43 | 44 | * fixed setup to pull README once more, even in Python 2 45 | 46 | 1.1.0 47 | ----- 48 | 49 | Memoization, prep for v 2.0 50 | 51 | * ``memoize`` decorator - memoize any callable 52 | * ``LRUCache``, ``FIFOCache``, and ``TimedCace`` - to make the ``memoize`` 53 | decorator more useful 54 | * ``Decorated`` class, prep for v 2.0 55 | * ``_use_future_syntax`` option, prepping for version 2.0 56 | 57 | 1.0.0 58 | ----- 59 | 60 | Initial release! 61 | 62 | * ``before`` decorator - run a callback prior to the decorated function 63 | * ``after`` decorator - run a callback after the decorated function 64 | * ``instead`` decorator - run a callback instead of the decorated function 65 | * ``decorate`` decorator - specify before, after, and/or instead callbacks 66 | all at once 67 | * ``construct_decorator`` function - create a reusable decorator with 68 | before, after, and/or instead callbacks 69 | * ``intercept`` decorator - wrap the decorated function in a try/except, 70 | specifying a function with which to handle the exception and/or another 71 | exception to re-raise 72 | * ``log_call`` decorator - automatically log the decorated functions's 73 | call signature and results 74 | 75 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at msplanchard [at] gmail [dot] com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Matthew Planchard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include requirements-dev.txt 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV = . ./venv/bin/activate; 2 | PKG_DIR = src 3 | TEST_DIR = tests 4 | PKG_NAME = pydecor 5 | SRC_FILES = *.py $(PKG_DIR) $(TEST_DIR) 6 | TEST = pytest \ 7 | --cov-config=setup.cfg \ 8 | --cov=$(PKG_NAME) \ 9 | --cov-report=term \ 10 | --cov-report=xml:coverage.xml \ 11 | --doctest-modules \ 12 | $(PKG_DIR) \ 13 | $(TEST_DIR) 14 | 15 | .PHONY: bench build clean distribute fmt lint test 16 | 17 | all: fmt lint test 18 | 19 | venv: venv/bin/activate 20 | venv/bin/activate: setup.py requirements.txt requirements-dev.txt 21 | python3 -m venv venv 22 | $(VENV) pip install -e .[dev] 23 | touch venv/bin/activate 24 | 25 | 26 | venv-clean: 27 | rm -rf venv 28 | 29 | 30 | venv-refresh: venv-clean venv 31 | $(VENV) pip install -e .[dev] 32 | 33 | 34 | venv-update: venv 35 | $(VENV) pip install -e .[dev] 36 | 37 | 38 | build: venv build-clean 39 | $(VENV) python setup.py sdist bdist_wheel 40 | 41 | 42 | build-clean: 43 | rm -rf build dist 44 | rm -rf src/*.egg-info 45 | 46 | clean: 47 | find . -type f -name "*.py[co]" -delete 48 | find . -type d -name "__pycache__" -delete 49 | 50 | # Requires VERSION to be set on the CLI or in an environment variable, 51 | # e.g. make VERSION=1.0.0 distribute 52 | distribute: build 53 | $(VENV) scripts/check_ready_to_distribute.py $(VERSION) 54 | git tag -s "v$(VERSION)" 55 | git push --tags 56 | 57 | SRC_INPUTS := $(shell find src/pydecor -name \*.py -print) 58 | DOC_INPUTS := $(shell find docs -name \*.rst -print) 59 | docs: venv docs/conf.py docs/Makefile $(DOC_INPUTS) $(SRC_INPUTS) 60 | rm -rf docs/_build 61 | $(VENV) pip install -r docs/requirements.txt 62 | $(VENV) sphinx-apidoc -o docs src/pydecor --separate -f 63 | make text --directory=docs 64 | make html --directory=docs 65 | touch docs/ 66 | 67 | # DOC_INPUTS := $(shell find src/pydecor -name \*.py -print) 68 | # docs/index.rst: $(DOC_INPUTS) docs/conf.py docs-clean 69 | # rm -rf docs/_build 70 | # $(VENV) pip install -r docs/requirements.txt 71 | # $(VENV) sphinx-apidoc -o docs src/pydecor --separate -f 72 | # make text --directory=docs 73 | # make html --directory=docs 74 | # touch docs/index.rst 75 | 76 | fmt: venv 77 | $(VENV) black $(SRC_FILES) 78 | 79 | lint: venv 80 | $(VENV) black --check $(SRC_FILES) 81 | $(VENV) flake8 $(SRC_FILES) 82 | $(VENV) mypy $(SRC_FILES) 83 | $(VENV) pylint --errors-only $(SRC_FILES) 84 | # $(VENV) pydocstyle $(SRC_FILES) 85 | 86 | setup: venv-clean venv 87 | 88 | test: venv 89 | $(VENV) $(TEST) 90 | 91 | tox: venv 92 | TOXENV=$(TOXENV) tox 93 | 94 | test-docker-3.6: 95 | docker run --rm -it --mount type=bind,source="$(PWD)",target="/src" -w "/src" \ 96 | python:3.6 bash -c "make clean && pip install -e .[dev] && $(TEST); make clean" 97 | 98 | test-docker-3.7: 99 | docker run --rm -it --mount type=bind,source="$(PWD)",target="/src" -w "/src" \ 100 | python:3.7 bash -c "make clean && pip install -e .[dev] && $(TEST); make clean" 101 | 102 | test-docker-3.8: 103 | docker run --rm -it --mount type=bind,source="$(PWD)",target="/src" -w "/src" \ 104 | python:3.8 bash -c "make clean && pip install -e .[dev] && $(TEST); make clean" 105 | 106 | test-docker-pypy: 107 | docker run --rm -it --mount type=bind,source="$(PWD)",target="/src" -w "/src" \ 108 | pypy:3 bash -c "make clean && pip install -e .[dev] && $(TEST); make clean" 109 | 110 | test-docker: test-docker-3.6 test-docker-3.7 test-docker-3.8 test-docker-pypy3 111 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PyDecor 2 | ======= 3 | 4 | .. image:: https://travis-ci.org/mplanchard/pydecor.svg?branch=master 5 | :target: https://travis-ci.org/mplanchard/pydecor 6 | 7 | .. image:: https://readthedocs.org/projects/pydecor/badge/?version=latest 8 | :target: https://pydecor.readthedocs.io/ 9 | 10 | Easy-peasy Python decorators! 11 | 12 | * GitHub: https://github.com/mplanchard/pydecor 13 | * PyPI: https://pypi.python.org/pypi/pydecor 14 | * Docs: https://pydecor.readthedocs.io/ 15 | * Contact: ``msplanchard`` ``@`` ``gmail`` or @msplanchard on Twitter 16 | 17 | 18 | Summary 19 | ------- 20 | 21 | Decorators are great, but they're hard to write, especially if you want 22 | to include arguments to your decorators, or use your decorators on 23 | class methods as well as functions. I know that, no matter how many I write, 24 | I still find myself looking up the syntax every time. And that's just for 25 | simple function decorators. Getting decorators to work consistently at the 26 | class and method level is a whole 'nother barrel of worms. 27 | 28 | PyDecor aims to make function decoration easy and straightforward, so that 29 | developers can stop worrying about closures and syntax in triply nested 30 | functions and instead get down to decorating! 31 | 32 | 33 | .. contents:: Table of Contents 34 | 35 | 36 | Quickstart 37 | ---------- 38 | 39 | Install ``pydecor``:: 40 | 41 | pip install pydecor 42 | 43 | Use one of the ready-to-wear decorators: 44 | 45 | .. code:: python 46 | 47 | # Memoize a function 48 | 49 | from pydecor import memoize 50 | 51 | 52 | @memoize() 53 | def fibonacci(n): 54 | """Compute the given number of the fibonacci sequence""" 55 | if n < 2: 56 | return n 57 | return fibonacci(n - 2) + fibonacci(n - 1) 58 | 59 | print(fibonacci(150)) 60 | 61 | 62 | .. code:: python 63 | 64 | # Intercept an error and raise a different one 65 | 66 | from flask import Flask 67 | from pydecor import intercept 68 | from werkzeug.exceptions import InternalServerError 69 | 70 | 71 | app = Flask(__name__) 72 | 73 | 74 | @app.route('/') 75 | @intercept(catch=Exception, reraise=InternalServerError, 76 | err_msg='The server encountered an error rendering "some_view"') 77 | def some_view(): 78 | """The root view""" 79 | assert False 80 | return 'Asserted False successfully!' 81 | 82 | 83 | client = app.test_client() 84 | response = client.get('/') 85 | 86 | assert response.status_code == 500 87 | assert 'some_view'.encode() in resp.data 88 | 89 | 90 | Use a generic decorator to run your own functions ``@before``, ``@after``, 91 | or ``@instead`` of another function, like in the following example, 92 | which sets a User-Agent header on a Flask response: 93 | 94 | .. code:: python 95 | 96 | from flask import Flask, make_response 97 | from pydecor import Decorated, after 98 | 99 | 100 | app = Flask(__name__) 101 | 102 | # `Decorated` instances are passed to your functions and contain 103 | # information about the wrapped function, including its `args`, 104 | # `kwargs`, and `result`, if it's been called. 105 | 106 | def set_user_agent(decorated: Decorated): 107 | """Sets the user-agent header on a result from a view""" 108 | resp = make_response(decorated.result) 109 | resp.headers.set('User-Agent', 'my_applicatoin') 110 | return resp 111 | 112 | 113 | @app.route('/') 114 | @after(set_user_agent) 115 | def index_view(): 116 | return 'Hello, world!' 117 | 118 | 119 | client = app.test_client() 120 | response = client.get('/') 121 | assert response.headers.get('User-Agent') == 'my_application' 122 | 123 | 124 | Or make your own decorator with ``construct_decorator`` 125 | 126 | .. code:: python 127 | 128 | from flask import reques 129 | from pydecor import Decorated, construct_decorator 130 | from werkzeug.exceptions import Unauthorized 131 | 132 | 133 | def check_auth(_decorated: Decorated, request): 134 | """Theoretically checks auth. 135 | 136 | It goes without saying, but this is example code. You should 137 | not actually check auth this way! 138 | """ 139 | if request.host != 'localhost': 140 | raise Unauthorized('locals only!') 141 | 142 | 143 | authed = construct_decorator(before=check_auth) 144 | 145 | 146 | app = Flask(__name__) 147 | 148 | 149 | @app.route('/') 150 | # Any keyword arguments provided to any of the generic decorators are 151 | # passed directly to your callable. 152 | @authed(request=request) 153 | def some_view(): 154 | """An authenticated view""" 155 | return 'This is sensitive data!' 156 | 157 | 158 | Why PyDecor? 159 | ------------ 160 | 161 | * **It's easy!** 162 | 163 | With PyDecor, you can go from this: 164 | 165 | .. code:: python 166 | 167 | from functools import wraps 168 | from flask import request 169 | from werkzeug.exceptions import Unauthorized 170 | from my_pkg.auth import authorize_request 171 | 172 | def auth_decorator(request=None): 173 | """Check the passed request for authentication""" 174 | 175 | def decorator(decorated): 176 | 177 | @wraps(decorated) 178 | def wrapper(*args, **kwargs): 179 | if not authorize_request(request): 180 | raise Unauthorized('Not authorized!') 181 | return decorated(*args, **kwargs) 182 | return wrapper 183 | 184 | return decorator 185 | 186 | @auth_decorator(request=requst) 187 | def some_view(): 188 | return 'Hello, World!' 189 | 190 | to this: 191 | 192 | .. code:: python 193 | 194 | from flask import request 195 | from pydecor import before 196 | from werkzeug.exceptions import Unauthorized 197 | from my_pkg.auth import authorize_request 198 | 199 | def check_auth(_decorated, request=request): 200 | """Ensure the request is authorized""" 201 | if not authorize_request(request): 202 | raise Unauthorized('Not authorized!') 203 | 204 | @before(check_auth, request=request) 205 | def some_view(): 206 | return 'Hello, world!' 207 | 208 | Not only is it less code, but you don't have to remember decorator 209 | syntax or mess with nested functions. Full disclosure, I had to look 210 | up a decorator sample to be sure I got the first example's syntax right, 211 | and I just spent two weeks writing a decorator library. 212 | 213 | * **It's fast!** 214 | 215 | PyDecor aims to make your life easier, not slower. The decoration machinery 216 | is designed to be as efficient as is reasonable, and contributions to 217 | speed things up are always welcome. 218 | 219 | * **Implicit Method Decoration!** 220 | 221 | Getting a decorator to "roll down" to methods when applied to a class is 222 | a complicated business, but all of PyDecor's decorators provide it for 223 | free, so rather than writing: 224 | 225 | .. code:: python 226 | 227 | from pydecor import log_call 228 | 229 | class FullyLoggedClass(object): 230 | 231 | @log_call(level='debug') 232 | def some_function(self, *args, **kwargs): 233 | return args, kwargs 234 | 235 | @log_call(level='debug') 236 | def another_function(self, *args, **kwargs): 237 | return None 238 | 239 | ... 240 | 241 | You can just write: 242 | 243 | .. code:: python 244 | 245 | from pydecor import log_call 246 | 247 | @log_call(level='debug') 248 | class FullyLoggedClass(object): 249 | 250 | def some_function(self, *args, **kwargs): 251 | return args, kwargs 252 | 253 | def another_function(self, *args, **kwargs): 254 | return None 255 | 256 | ... 257 | 258 | PyDecor ignores special methods (like ``__init__``) so as not to interfere 259 | with deep Python magic. By default, it works on any methods of a class, 260 | including instance, class and static methods. It also ensures that class 261 | attributes are preserved after decoration, so your class references 262 | continue to behave as expected. 263 | 264 | * **Consistent Method Decoration!** 265 | 266 | Whether you're decorating a class, an instance method, a class method, or 267 | a static method, you can use the same passed function. ``self`` and ``cls`` 268 | variables are stripped out of the method parameters passed to the provided 269 | callable, so your functions don't need to care about where they're used. 270 | 271 | * **Lots of Tests!** 272 | 273 | Seriously. Don't believe me? Just look. We've got the best tests. Just 274 | phenomenal. 275 | 276 | 277 | Installation 278 | ------------ 279 | 280 | **`pydecor` 2.0 and forward supports only Python 3.6+!** 281 | 282 | If you need support for an older Python, use the most recent 1.x release. 283 | 284 | To install `pydecor`, simply run:: 285 | 286 | pip install -U pydecor 287 | 288 | To install the current development release:: 289 | 290 | pip install --pre -U pydecor 291 | 292 | You can also install from source to get the absolute most recent 293 | code, which may or may not be functional:: 294 | 295 | git clone https://github.com/mplanchard/pydecor 296 | pip install ./pydecor 297 | 298 | 299 | 300 | Details 301 | ------- 302 | 303 | Provided Decorators 304 | ******************* 305 | 306 | This package provides generic decorators, which can be used with any 307 | function to provide extra utility to decorated resources, as well 308 | as prête-à-porter (ready-to-wear) decorators for immediate use. 309 | 310 | While the information below is enough to get you started, I highly 311 | recommend checking out the `decorator module docs`_ to see all the 312 | options and details for the various decorators! 313 | 314 | Generics 315 | ~~~~~~~~ 316 | 317 | * ``@before`` - run a callable before the decorated function executes 318 | 319 | * called with an instance of `Decorated` and any provided kwargs 320 | 321 | * ``@after`` - run a callable after the decorated function executes 322 | 323 | * called with an instance of `Decorated` and any provided kwargs 324 | 325 | * ``@instead`` - run a callable in place of the decorated function 326 | 327 | * called with an instance of `Decorated` and any provided kwargs 328 | 329 | * ``@decorate`` - specify multiple callables to be run before, after, and/or 330 | instead of the decorated function 331 | 332 | * callables passed to ``decorate``'s ``before``, ``after``, or ``instead`` 333 | keyword arguments will be called with the same default function signature 334 | as described for the individual decorators, above. Extras will be 335 | passed to all provided callables. 336 | 337 | * ``construct_decorator`` - specify functions to be run ``before``, ``after``, 338 | or ``instead`` of decorated functions. Returns a reusable decorator. 339 | 340 | The callable passed to a generic decorator is expected to handle at least one 341 | positional argument, which will be an instance of `Decorated`. `Decorated` 342 | objects provide the following interface: 343 | 344 | **Attributes:** 345 | 346 | * `args`: a tuple of any positional arguments with which the decorated 347 | callable was called 348 | * `kwargs`: a dict of any keyword arguments with which the decorated 349 | callable was called 350 | * `wrapped`: a reference to the decorated callable 351 | * `result`: when the _wrapped_ function has been called, its return value is 352 | stored here 353 | 354 | **Methods** 355 | 356 | * `__call__(*args, **kwargs)`: a shortcut to 357 | `decorated.wrapped(*args, **kwargs)`, calling an instance of `Decorated` 358 | calls the underlying wrapped callable. The result of this call (or a 359 | direct call to `decorated.wrapped()`) will set the `result` attribute. 360 | 361 | Every generic decorator may take any number of keyword arguments, which will be 362 | passed directly into the provided callable, so, running the code below prints 363 | "red": 364 | 365 | .. code:: python 366 | 367 | from pydecor import before 368 | 369 | def before_func(_decorated, label=None): 370 | print(label) 371 | 372 | @before(before_func, label='red') 373 | def red_function(): 374 | pass 375 | 376 | red_function() 377 | 378 | Every generic decorator takes the following keyword arguments: 379 | 380 | * ``implicit_method_decoration`` - if True, decorating a class implies 381 | decorating all of its methods. **Caution:** you should probably leave this 382 | on unless you know what you are doing. 383 | * ``instance_methods_only`` - if True, only instance methods (not class or 384 | static methods) will be automatically decorated when 385 | ``implicit_method_decoration`` is True 386 | 387 | The ``construct_decorator`` function can be used to combine ``@before``, 388 | ``@after``, and ``@instead`` calls into one decorator, without having to 389 | worry about unintended stacking effects. Let's make a 390 | decorator that announces when we're starting an exiting a function: 391 | 392 | .. code:: python 393 | 394 | from pydecor import construct_decorator 395 | 396 | def before_func(decorated): 397 | print('Starting decorated function ' 398 | '"{}"'.format(decorated.wrapped.__name__)) 399 | 400 | def after_func(decorated): 401 | print('"{}" gave result "{}"'.format( 402 | decorated.wrapped.__name__, decorated.result 403 | )) 404 | 405 | my_decorator = construct_decorator(before=before_func, after=after_func) 406 | 407 | @my_decorator() 408 | def this_function_returns_nothing(): 409 | return 'nothing' 410 | 411 | And the output? 412 | 413 | .. code:: 414 | 415 | Starting decorated function "this_function_returns_nothing" 416 | "this_function_returns_nothing" gave result "nothing" 417 | 418 | 419 | Maybe a more realistic example would be useful. Let's say we want to add 420 | headers to a Flask response. 421 | 422 | .. code:: python 423 | 424 | 425 | from flask import Flask, Response, make_response 426 | from pydecor import construct_decorator 427 | 428 | 429 | def _set_app_json_header(decorated): 430 | # Ensure the response is a Response object, even if a tuple was 431 | # returned by the view function. 432 | response = make_response(decorated.result) 433 | response.headers.set('Content-Type', 'application/json') 434 | return response 435 | 436 | 437 | application_json = construct_decorator(after=_set_app_json_header) 438 | 439 | 440 | # Now you can decorate any Flask view, and your headers will be set. 441 | 442 | app = Flask(__name__) 443 | 444 | # Note that you must decorate "before" (closer to) the function than the 445 | # app.route() decoration, because the route decorator must be called on 446 | # the "finalized" version of your function 447 | 448 | @app.route('/') 449 | @application_json() 450 | def root_view(): 451 | return 'Hello, world!' 452 | 453 | client = app.test_client() 454 | response = app.get('/') 455 | 456 | print(response.headers) 457 | 458 | 459 | The output? 460 | 461 | ..code:: 462 | 463 | Content-Type: application/json 464 | Content-Length: 13 465 | 466 | 467 | Prête-à-porter (ready-to-wear) 468 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 469 | 470 | * ``export`` - add the decorated class or function to its module's `__all__` 471 | list, exposing it as a "public" reference. 472 | * ``intercept`` - catch the specified exception and optionally re-raise and/or 473 | call a provided callback to handle the exception 474 | * ``log_call`` - automatically log the decorated function's call signature and 475 | results 476 | * ``memoize`` - memoize a function's call and return values for re-use. Can 477 | use any cache in ``pydecor.caches``, which all have options for automatic 478 | pruning to keep the memoization cache from growing too large. 479 | 480 | Caches 481 | ****** 482 | 483 | Three caches are provided with ``pydecor``. These are designed to be passed 484 | to the ``@memoization`` decorator if you want to use something other than 485 | the default ``LRUCache``, but they are perfectly functional for use elsewhere. 486 | 487 | All caches implement the standard dictionary interface. 488 | 489 | 490 | LRUCache 491 | ~~~~~~~~ 492 | 493 | A least-recently-used cache. Both getting and setting of key/value pairs 494 | results in their having been considered most-recently-used. When the cache 495 | reaches the specified ``max_size``, least-recently-used items are discarded. 496 | 497 | FIFOCache 498 | ~~~~~~~~~ 499 | 500 | A first-in, first-out cache. When the cache reaches the specified ``max_size``, 501 | the first item that was inserted is discarded, then the second, and so on. 502 | 503 | TimedCache 504 | ~~~~~~~~~~ 505 | 506 | A cache whose entries expire. If a ``max_age`` is specified, any entries older 507 | than the ``max_age`` (in seconds) will be considered invalid, and will be 508 | removed upon access. 509 | 510 | 511 | Stacking 512 | ******** 513 | 514 | Generic and ready-to-wear decorators may be stacked! You can stack multiple 515 | of the same decorator, or you can mix and match. Some gotchas are listed 516 | below. 517 | 518 | Generally, stacking works just as you might expect, but some care must be 519 | taken when using the ``@instead`` decorator, or ``@intercept``, which 520 | uses ``@instead`` under the hood. 521 | 522 | Just remember that ``@instead`` replaces everything that comes before. So, 523 | as long as ``@instead`` calls the decorated function, it's okay to stack it. 524 | In these cases, it will be called *before* any decorators specified below 525 | it, and those decorators will be executed when it calls the decorated function. 526 | ``@intercept`` behaves this way, too. 527 | 528 | If an ``@instead`` decorator does *not* call the decorated function and 529 | instead replaces it entirely, it **must** be specified first (at the bottom 530 | of the stacked decorator pile), otherwise the decorators below it will not 531 | execute. 532 | 533 | For ``@before`` and ``@after``, it doesn't matter in what order the decorators 534 | are specified. ``@before`` is always called first, and ``@after`` last. 535 | 536 | 537 | Class Decoration 538 | **************** 539 | 540 | Class decoration is difficult, but PyDecor aims to make it as easy and 541 | intuitive as possible! 542 | 543 | By default, decorating a class applies that decorator to all of that class' 544 | methods (instance, class, and static). The decoration applies to class and 545 | static methods whether they are referenced via an instance or via a class 546 | reference. "Extras" specified at the class level persist across calls to 547 | different methods, allowing for things like a class level memoization 548 | dictionary (there's a very basic test in the test suite 549 | that demonstrates this pattern). 550 | 551 | If you'd prefer that the decorator not apply to class and static methods, 552 | set the ``instance_methods_only=True`` when decorating the class. 553 | 554 | If you want to decorate the class itself, and *not* its methods, keep in 555 | mind that the decorator will be triggered when the class is instantiated, 556 | and that, if the decorator replaces or alters the return, that return will 557 | replace the instantiated class. With those caveats in mind, setting 558 | ``implicit_method_decoration=False`` when decorating a class enables that 559 | functionality. 560 | 561 | .. note:: 562 | 563 | Class decoration, and in particular the decoration of class and static 564 | methods, is accomplished through some pretty deep, complicated magic. 565 | The test suite has a lot of tests trying to make sure that everything 566 | works as expected, but please report any bugs you find so that I 567 | can resolve them! 568 | 569 | 570 | Method Decoration 571 | ***************** 572 | 573 | Decorators can be applied to static, class, or instance methods directly, as 574 | well. If combined with ``@staticmethod`` or ``@classmethod`` decorators, 575 | those decorators should always be at the "top" of the decorator stack 576 | (furthest from the function). 577 | 578 | When decorating instance methods, ``self`` is removed from the parameters 579 | passed to the provided callable. 580 | 581 | When decorating class methods, ``cls`` is removed from the parameters passed 582 | to the provided callable. 583 | 584 | Currently, the class and instance references *do not* have to be named 585 | ``"cls"`` and ``"self"``, respectively, in order to be removed. However, 586 | this is not guaranteed for future releases, so try to keep your naming 587 | standard if you can (just FYI, ``"self"`` is the more likely of the two to 588 | wind up being required). 589 | 590 | Examples 591 | -------- 592 | 593 | Below are some examples for the generic and standard decorators. Please 594 | check out the API Docs for more information, and also check out the 595 | convenience decorators, which are all implemented using the 596 | ``before``, ``after``, and ``instead`` decorators from this library. 597 | 598 | Update a Function's Args or Kwargs 599 | ********************************** 600 | 601 | Functions passed to ``@before`` can either return None, in which case nothing 602 | happens to the decorated functions parameters, or they can return a tuple 603 | of args (as a tuple) and kwargs (as a dict), in which case those parameters 604 | are used in the decorated function. In this example, we sillify a very 605 | serious function. 606 | 607 | .. note:: 608 | Because kwargs are mutable, they can be updated even if the function 609 | passed to `before` doesn't return anything. 610 | 611 | .. code:: python 612 | 613 | from pydecor import before 614 | 615 | def spamify_func(decorated): 616 | """Mess with the function arguments""" 617 | args = tuple(['spam' for _ in decorated.args]) 618 | kwargs = {k: 'spam' for k in decorated.kwargs} 619 | return args, kwargs 620 | 621 | 622 | @before(spamify_func) 623 | def serious_function(serious_string, serious_kwarg='serious'): 624 | """A very serious function""" 625 | print('A serious arg: {}'.format(serious_string)) 626 | print('A serious kwarg: {}'.format(serious_kwarg)) 627 | 628 | serious_function('Politics', serious_kwarg='Religion') 629 | 630 | The output? 631 | 632 | .. code:: 633 | 634 | A serious arg: spam 635 | A serious kwarg: spam 636 | 637 | Do Something with a Function's Return Value 638 | ******************************************* 639 | 640 | Functions passed to ``@after`` receive the decorated function's return value 641 | as part of the `Decorated` instance. If ``@after`` returns None, the return 642 | value is sent back unchanged. However, if ``@after`` returns something, 643 | its return value is sent back as the return value of the function. 644 | 645 | In this example, we ensure that a function's return value has been thoroughly 646 | spammified. 647 | 648 | .. code:: python 649 | 650 | from pydecor import after 651 | 652 | def spamify_return(decorated): 653 | """Spamify the result of a function""" 654 | return 'spam-spam-spam-spam-{}-spam-spam-spam-spam'.format(decorated.result) 655 | 656 | 657 | @after(spamify_return) 658 | def unspammed_function(): 659 | """Return a non-spammy value""" 660 | return 'beef' 661 | 662 | print(unspammed_function()) 663 | 664 | The output? 665 | 666 | .. code:: 667 | 668 | spam-spam-spam-spam-beef-spam-spam-spam-spam 669 | 670 | 671 | Do Something Instead of a Function 672 | ********************************** 673 | 674 | Functions passed to ``@instead`` also provide wrapped context via the 675 | `Decorated` object. But, if the `instead` callable does not call the 676 | wrapped function, it won't get called at all. Maybe you want to skip 677 | a function when a certain condition is True, but you don't want to use 678 | ``pytest.skipif``, because ``pytest`` can't be a dependency of your 679 | production code for whatever reason. 680 | 681 | 682 | .. code:: python 683 | 684 | from pydecor import instead 685 | 686 | def skip(decorated, when=False): 687 | if when: 688 | pass 689 | else: 690 | # Calling `decorated` calls the wrapped function. 691 | return decorated(*decorated.args, **decorated.kwargs) 692 | 693 | 694 | @instead(skip, when=True) 695 | def uncalled_function(): 696 | print("You won't see me (you won't see me)") 697 | 698 | 699 | uncalled_function() 700 | 701 | The output? 702 | 703 | (There is no output, because the function was skipped) 704 | 705 | 706 | Automatically Log Function Calls and Results 707 | ******************************************** 708 | 709 | Maybe you want to make sure your functions get logged without having to 710 | bother with the logging boilerplate each time. ``@log_call`` tries to 711 | automatically get a logging instance corresponding to the module 712 | in which the decoration occurs (in the same way as if you made a call 713 | to ``logging.getLogger(__name__)``, or you can pass it your own, fancy, 714 | custom, spoiler-bedecked logger instance. 715 | 716 | .. code:: python 717 | 718 | from logging import getLogger, StreamHandler 719 | from sys import stdout 720 | 721 | from pydecor import log_call 722 | 723 | 724 | # We're just getting a logger here so we can see the output. This isn't 725 | # actually necessary for @log_call to work! 726 | log = getLogger(__name__) 727 | log.setLevel('DEBUG') 728 | log.addHandler(StreamHandler(stdout)) 729 | 730 | 731 | @log_call() 732 | def get_lucky(*args, **kwargs): 733 | """We're up all night 'till the sun.""" 734 | return "We're up all night for good fun." 735 | 736 | 737 | get_lucky('Too far', 'to give up', who_we='are') 738 | 739 | 740 | And the output? 741 | 742 | .. code:: 743 | 744 | get_lucky(*('Too far', 'to give up'), **{'who_we': 'are'}) -> "We're up all night for good fun" 745 | 746 | 747 | Intercept an Exception and Re-raise a Custom One 748 | ************************************************ 749 | 750 | Are you a put-upon library developer tired of constantly having to re-raise 751 | custom exceptions so that users of your library can have one nice try/except 752 | looking for your base exception? Let's make that easier: 753 | 754 | .. code:: python 755 | 756 | from pydecor import intercept 757 | 758 | 759 | class BetterException(Exception): 760 | """Much better than all those other exceptions""" 761 | 762 | 763 | @intercept(catch=RuntimeError, reraise=BetterException) 764 | def sometimes_i_error(val): 765 | """Sometimes, this function raises an exception""" 766 | if val > 5: 767 | raise RuntimeError('This value is too big!') 768 | 769 | 770 | for i in range(7): 771 | sometimes_i_error(i) 772 | 773 | 774 | The output? 775 | 776 | .. code:: 777 | 778 | Traceback (most recent call last): 779 | File "/Users/Nautilus/Library/Preferences/PyCharm2017.1/scratches/scratch_1.py", line 88, in 780 | sometimes_i_error(i) 781 | File "/Users/Nautilus/Documents/Programming/pydecor/pydecor/decorators.py", line 389, in wrapper 782 | return fn(**fkwargs) 783 | File "/Users/Nautilus/Documents/Programming/pydecor/pydecor/functions.py", line 58, in intercept 784 | raise_from(new_exc, context) 785 | File "", line 2, in raise_from 786 | __main__.BetterException: This value is too big! 787 | 788 | 789 | Intercept an Exception, Do Something, and Re-raise the Original 790 | *************************************************************** 791 | 792 | Maybe you don't *want* to raise a custom exception. Maybe the original 793 | one was just fine. All you want to do is print a special message before 794 | re-raising the original exception. PyDecor has you covered: 795 | 796 | .. code:: python 797 | 798 | from pydecor import intercept 799 | 800 | 801 | def print_exception(exc): 802 | """Make sure stdout knows about our exceptions""" 803 | print('Houston, we have a problem: {}'.format(exc)) 804 | 805 | 806 | @intercept(catch=Exception, handler=print_exception, reraise=True) 807 | def assert_false(): 808 | """All I do is assert that False is True""" 809 | assert False, 'Turns out, False is not True' 810 | 811 | 812 | assert_false() 813 | 814 | And the output: 815 | 816 | .. code:: 817 | 818 | Houston, we have a problem: Turns out, False is not True 819 | Traceback (most recent call last): 820 | File "/Users/Nautilus/Library/Preferences/PyCharm2017.1/scratches/scratch_1.py", line 105, in 821 | assert_false() 822 | File "/Users/Nautilus/Documents/Programming/pydecor/pydecor/decorators.py", line 389, in wrapper 823 | return fn(**fkwargs) 824 | File "/Users/Nautilus/Documents/Programming/pydecor/pydecor/functions.py", line 49, in intercept 825 | return decorated(*decorated_args, **decorated_kwargs) 826 | File "/Users/Nautilus/Library/Preferences/PyCharm2017.1/scratches/scratch_1.py", line 102, in assert_false 827 | assert False, 'Turns out, False is not True' 828 | AssertionError: Turns out, False is not True 829 | 830 | 831 | Intercept an Exception, Handle, and Be Done with It 832 | *************************************************** 833 | 834 | Sometimes an exception isn't the end of the world, and it doesn't need to 835 | bubble up to the top of your application. In these cases, maybe just handle 836 | it and don't re-raise: 837 | 838 | .. code:: python 839 | 840 | from pydecor import intercept 841 | 842 | 843 | def let_us_know_it_happened(exc): 844 | """Just let us know an exception happened (if we are reading stdout)""" 845 | print('This non-critical exception happened: {}'.format(exc)) 846 | 847 | 848 | @intercept(catch=ValueError, handler=let_us_know_it_happened) 849 | def resilient_function(val): 850 | """I am so resilient!""" 851 | val = int(val) 852 | print('If I get here, I have an integer: {}'.format(val)) 853 | 854 | 855 | resilient_function('50') 856 | resilient_function('foo') 857 | 858 | Output: 859 | 860 | .. code:: 861 | 862 | If I get here, I have an integer: 50 863 | This non-critical exception happened: invalid literal for int() with base 10: 'foo' 864 | 865 | Note that the function does *not* continue running after the exception is 866 | handled. Use this for short-circuiting under certain conditions rather 867 | than for instituting a ``try/except:pass`` block. Maybe one day I'll figure 868 | out how to make this work like that, but as it stands, the decorator surrounds 869 | the entire function, so it does not provide that fine-grained level of control. 870 | 871 | 872 | Roadmap 873 | ------- 874 | 875 | 2.? 876 | *** 877 | 878 | More Prête-à-porter Decorators 879 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 880 | 881 | * ``skipif`` - similar to py.test's decorator, skip the function if a 882 | provided condition is True 883 | 884 | Let me know if you've got any idea for other decorators that would 885 | be nice to have! 886 | 887 | 888 | Type Annotations 889 | ~~~~~~~~~~~~~~~~ 890 | 891 | Now that we've dropped support for Python 2, we can use type annotations 892 | to properly annotate function inputs and return values and make them 893 | available to library authors. 894 | 895 | Contributing 896 | ------------ 897 | 898 | Contributions are welcome! If you find a bug or if something doesn't 899 | work the way you think it should, please `raise an issue `_. 900 | If you know how to fix the bug, please `open a PR! `_ 901 | 902 | I absolutely welcome any level of contribution. If you think the docs 903 | could be better, or if you've found a typo, please open up a PR to improve 904 | and/or fix them. 905 | 906 | Contributor Conduct 907 | ******************* 908 | 909 | There is a ``CODE_OF_CONDUCT.md`` file with details, based on one of GitHub's 910 | templates, but the upshot is that I expect everyone who contributes to this 911 | project to do their best to be helpful, friendly, and patient. Discrimination 912 | of any kind will not be tolerated and will be promptly reported to GitHub. 913 | 914 | On a personal note, Open Source survives because of people who are willing to 915 | contribute their time and effort for free. The least we can do is treat them 916 | with respect. 917 | 918 | Tests 919 | ***** 920 | 921 | Tests can be run with:: 922 | 923 | make test 924 | 925 | This will use whatever your local `python3` happens to be. If you have 926 | other pythons available, you can run:: 927 | 928 | make tox 929 | 930 | to try to run locally for all supported Python versions. 931 | 932 | If you have docker installed, you can run:: 933 | 934 | make test-docker-{version} # e.g. make test-docker-3.6 935 | 936 | to pull down an appropriate Docker image and run tests inside of it. You can 937 | also run:: 938 | 939 | make test-docker 940 | 941 | to do this for all supported versions of Python. 942 | 943 | PRs that cause tests to fail will not be merged until tests pass. 944 | 945 | Any new functionality is expected to come with appropriate tests. 946 | If you have any questions, feel free to reach out to me via email at 947 | ``msplanchard`` ``@`` ``gmail`` or on GH via Issues. 948 | 949 | Autoformatting 950 | ************** 951 | 952 | This project uses black_ for autoformatting. I recommend setting your editor 953 | up to format on save for this project, but you can also run:: 954 | 955 | make fmt 956 | 957 | to format everything. 958 | 959 | Linting 960 | ******* 961 | 962 | Linting can be run with:: 963 | 964 | make lint 965 | 966 | Currently, linting verifies that there are: 967 | 968 | * No flake8 errors 969 | * No mypy errors 970 | * No pylint errors 971 | * No files that need be formatted 972 | 973 | You should ensure that `make lint` returns 0 before opening a PR. 974 | 975 | Docs 976 | **** 977 | 978 | Docs are autogenerated via Sphinx. You can build them locally by running:: 979 | 980 | make docs 981 | 982 | You can then open `docs/_build/html/index.html` in your web browser of 983 | choice to see how the documentation will look with your changes. 984 | 985 | Deployment 986 | ********** 987 | 988 | Deployment is handled through pushing tags. Any tag pushed to GH causes 989 | a push to PyPI if the current version is not yet present there. 990 | 991 | Pushing the appropriate tag is made easier through the use of:: 992 | 993 | VERSION=1.0.0 make distribute 994 | 995 | where `VERSION` obviously should be the current version. This will verify 996 | the specified version matches the package's current version, check to be 997 | sure that the most recent master is being distributed, prompt for a message, 998 | and create and push a signed tag of the format `v{version}`. 999 | 1000 | 1001 | Credits and Links 1002 | ----------------- 1003 | 1004 | * This project was started using my generic `project template`_ 1005 | * Tests are run with pytest_ and tox_ 1006 | * Documentation built with sphinx_ 1007 | * Coverage information collected with coverage_ 1008 | * Pickling of objects provided via dill_ 1009 | 1010 | .. _black: https://github.com/psf/black 1011 | .. _`project template`: https://github.com/mplanchard/python_skeleton 1012 | .. _pytest: 1013 | .. _`py.test`: https://docs.pytest.org/en/latest/ 1014 | .. _tox: http://tox.readthedocs.org/ 1015 | .. _sphinx: http://www.sphinx-doc.org/en/stable/ 1016 | .. _coverage: https://coverage.readthedocs.io/en/coverage-4.4.1/ 1017 | .. _`mock backport`: https://mock.readthedocs.io/en/latest/# 1018 | .. _`pep 484`: https://www.python.org/dev/peps/pep-0484/ 1019 | .. _six: https://pythonhosted.org/six/ 1020 | .. _`typing backport`: https://pypi.org/project/typing/ 1021 | .. _docs: https://pydecor.readthedocs.io/en/latest/ 1022 | .. _`decorator module docs`: 1023 | https://pydecor.readthedocs.io/en/latest/pydecor.decorators.html 1024 | .. _issues: https://github.com/mplanchard/pydecor/issues 1025 | .. _PRs: https://github.com/mplanchard/pydecor/pulls 1026 | .. _dill: https://pypi.python.org/pypi/dill 1027 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -a 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/configurator.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/configurator.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/configurator" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/configurator" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This file is execfile()d with the current directory set to its 5 | # containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | from datetime import datetime 14 | from os.path import abspath, dirname, join, realpath 15 | from subprocess import PIPE, Popen 16 | 17 | cwd = dirname(realpath(__file__)) 18 | root = abspath(join(cwd, "..")) 19 | 20 | 21 | def get_setup_value(arg): 22 | """Get a value returned by setup.py""" 23 | proc = Popen( 24 | ["python", join(root, "setup.py"), "--{0}".format(arg)], 25 | stdout=PIPE, 26 | stderr=PIPE, 27 | ) 28 | out, err = proc.communicate() 29 | if proc.returncode != 0: 30 | raise RuntimeError(err) 31 | 32 | return out.strip().decode() 33 | 34 | 35 | NAME = get_setup_value("name") 36 | VERSION = get_setup_value("version") 37 | AUTHOR = get_setup_value("author") 38 | 39 | 40 | # If extensions (or modules to document with autodoc) are in another directory, 41 | # add these directories to sys.path here. If the directory is relative to the 42 | # documentation root, use os.path.abspath to make it absolute, like shown here. 43 | # sys.path.insert(0, os.path.abspath('.')) 44 | 45 | # -- General configuration ------------------------------------------------ 46 | 47 | # If your documentation needs a minimal Sphinx version, state it here. 48 | # needs_sphinx = '1.0' 49 | 50 | # Add any Sphinx extension module names here, as strings. They can be 51 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 52 | # ones. 53 | extensions = [ 54 | "sphinx.ext.autodoc", 55 | "sphinx.ext.intersphinx", 56 | "sphinx.ext.todo", 57 | "sphinx.ext.viewcode", 58 | "sphinx_autodoc_typehints", 59 | ] 60 | 61 | # Add any paths that contain templates here, relative to this directory. 62 | templates_path = ["_templates"] 63 | 64 | # The suffix(es) of source filenames. 65 | # You can specify multiple suffix as a list of string: 66 | # source_suffix = ['.rst', '.md'] 67 | source_suffix = ".rst" 68 | 69 | # The encoding of source files. 70 | source_encoding = "utf-8-sig" 71 | 72 | # The master toctree document. 73 | master_doc = "index" 74 | 75 | # General information about the project. 76 | project = NAME 77 | copyright = "{}, {}".format(datetime.now().year, AUTHOR) 78 | author = AUTHOR 79 | 80 | # The version info for the project you're documenting, acts as replacement for 81 | # |version| and |release|, also used in various other places throughout the 82 | # built documents. 83 | # 84 | 85 | # The short X.Y version. 86 | version = VERSION 87 | # The full version, including alpha/beta/rc tags. 88 | release = VERSION 89 | 90 | 91 | # The language for content autogenerated by Sphinx. Refer to documentation 92 | # for a list of supported languages. 93 | # 94 | # This is also used if you do content translation via gettext catalogs. 95 | # Usually you set "language" from the command line for these cases. 96 | language = None 97 | 98 | # There are two options for replacing |today|: either, you set today to some 99 | # non-false value, then it is used: 100 | # today = '' 101 | # Else, today_fmt is used as the format for a strftime call. 102 | # today_fmt = '%B %d, %Y' 103 | 104 | # List of patterns, relative to source directory, that match files and 105 | # directories to ignore when looking for source files. 106 | exclude_patterns = [] 107 | 108 | # The reST default role (used for this markup: `text`) to use for all 109 | # documents. 110 | # default_role = None 111 | 112 | # If true, '()' will be appended to :func: etc. cross-reference text. 113 | # add_function_parentheses = True 114 | 115 | # If true, the current module name will be prepended to all description 116 | # unit titles (such as .. function::). 117 | # add_module_names = True 118 | 119 | # If true, sectionauthor and moduleauthor directives will be shown in the 120 | # output. They are ignored by default. 121 | # show_authors = False 122 | 123 | # The name of the Pygments (syntax highlighting) style to use. 124 | pygments_style = "sphinx" 125 | 126 | # A list of ignored prefixes for module index sorting. 127 | # modindex_common_prefix = [] 128 | 129 | # If true, keep warnings as "system message" paragraphs in the built documents. 130 | # keep_warnings = False 131 | 132 | # If true, `todo` and `todoList` produce output, else they produce nothing. 133 | todo_include_todos = True 134 | 135 | 136 | # -- Options for HTML output ---------------------------------------------- 137 | 138 | # The theme to use for HTML and HTML Help pages. See the documentation for 139 | # a list of builtin themes. 140 | html_theme = "alabaster" 141 | 142 | # Theme options are theme-specific and customize the look and feel of a theme 143 | # further. For a list of options available for each theme, see the 144 | # documentation. 145 | # html_theme_options = {} 146 | 147 | # Add any paths that contain custom themes here, relative to this directory. 148 | # html_theme_path = [] 149 | 150 | # The name for this set of Sphinx documents. If None, it defaults to 151 | # " v documentation". 152 | # html_title = None 153 | 154 | # A shorter title for the navigation bar. Default is the same as html_title. 155 | # html_short_title = None 156 | 157 | # The name of an image file (relative to this directory) to place at the top 158 | # of the sidebar. 159 | # html_logo = None 160 | 161 | # The name of an image file (within the static path) to use as favicon of the 162 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 163 | # pixels large. 164 | # html_favicon = None 165 | 166 | # Add any paths that contain custom static files (such as style sheets) here, 167 | # relative to this directory. They are copied after the builtin static files, 168 | # so a file named "default.css" will overwrite the builtin "default.css". 169 | html_static_path = ["_static"] 170 | 171 | # Add any extra paths that contain custom files (such as robots.txt or 172 | # .htaccess) here, relative to this directory. These files are copied 173 | # directly to the root of the documentation. 174 | # html_extra_path = [] 175 | 176 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 177 | # using the given strftime format. 178 | # html_last_updated_fmt = '%b %d, %Y' 179 | 180 | # If true, SmartyPants will be used to convert quotes and dashes to 181 | # typographically correct entities. 182 | # html_use_smartypants = True 183 | 184 | # Custom sidebar templates, maps document names to template names. 185 | # html_sidebars = {} 186 | 187 | # Additional templates that should be rendered to pages, maps page names to 188 | # template names. 189 | # html_additional_pages = {} 190 | 191 | # If false, no module index is generated. 192 | # html_domain_indices = True 193 | 194 | # If false, no index is generated. 195 | # html_use_index = True 196 | 197 | # If true, the index is split into individual pages for each letter. 198 | # html_split_index = False 199 | 200 | # If true, links to the reST sources are added to the pages. 201 | # html_show_sourcelink = True 202 | 203 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 204 | # html_show_sphinx = True 205 | 206 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 207 | # html_show_copyright = True 208 | 209 | # If true, an OpenSearch description file will be output, and all pages will 210 | # contain a tag referring to it. The value of this option must be the 211 | # base URL from which the finished HTML is served. 212 | # html_use_opensearch = '' 213 | 214 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 215 | # html_file_suffix = None 216 | 217 | # Language to be used for generating the HTML full-text search index. 218 | # Sphinx supports the following languages: 219 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 220 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 221 | # html_search_language = 'en' 222 | 223 | # A dictionary with options for the search language support, empty by default. 224 | # Now only 'ja' uses this config value 225 | # html_search_options = {'type': 'default'} 226 | 227 | # The name of a javascript file (relative to the configuration directory) that 228 | # implements a search results scorer. If empty, the default will be used. 229 | # html_search_scorer = 'scorer.js' 230 | 231 | # Output file base name for HTML help builder. 232 | htmlhelp_basename = "{0}doc".format(NAME) 233 | 234 | # -- Options for LaTeX output --------------------------------------------- 235 | 236 | latex_elements = { 237 | # The paper size ('letterpaper' or 'a4paper'). 238 | #'papersize': 'letterpaper', 239 | # The font size ('10pt', '11pt' or '12pt'). 240 | #'pointsize': '10pt', 241 | # Additional stuff for the LaTeX preamble. 242 | #'preamble': '', 243 | # Latex figure (float) alignment 244 | #'figure_align': 'htbp', 245 | } 246 | 247 | # Grouping the document tree into LaTeX files. List of tuples 248 | # (source start file, target name, title, 249 | # author, documentclass [howto, manual, or own class]). 250 | latex_documents = [ 251 | ( 252 | master_doc, 253 | "{0}.tex".format(NAME), 254 | "{0} Documentation".format(NAME), 255 | AUTHOR, 256 | "manual", 257 | ) 258 | ] 259 | 260 | # The name of an image file (relative to this directory) to place at the top of 261 | # the title page. 262 | # latex_logo = None 263 | 264 | # For "manual" documents, if this is true, then toplevel headings are parts, 265 | # not chapters. 266 | # latex_use_parts = False 267 | 268 | # If true, show page references after internal links. 269 | # latex_show_pagerefs = False 270 | 271 | # If true, show URL addresses after external links. 272 | # latex_show_urls = False 273 | 274 | # Documents to append as an appendix to all manuals. 275 | # latex_appendices = [] 276 | 277 | # If false, no module index is generated. 278 | # latex_domain_indices = True 279 | 280 | 281 | # -- Options for manual page output --------------------------------------- 282 | 283 | # One entry per manual page. List of tuples 284 | # (source start file, name, description, authors, manual section). 285 | man_pages = [(master_doc, NAME, "{0} Documentation".format(NAME), [author], 1)] 286 | 287 | # If true, show URL addresses after external links. 288 | # man_show_urls = False 289 | 290 | 291 | # -- Options for Texinfo output ------------------------------------------- 292 | 293 | # Grouping the document tree into Texinfo files. List of tuples 294 | # (source start file, target name, title, author, 295 | # dir menu entry, description, category) 296 | texinfo_documents = [ 297 | ( 298 | master_doc, 299 | NAME, 300 | "{0} Documentation".format(NAME), 301 | author, 302 | NAME, 303 | "One line description of project.", 304 | "Miscellaneous", 305 | ) 306 | ] 307 | 308 | # Documents to append as an appendix to all manuals. 309 | # texinfo_appendices = [] 310 | 311 | # If false, no module index is generated. 312 | # texinfo_domain_indices = True 313 | 314 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 315 | # texinfo_show_urls = 'footnote' 316 | 317 | # If true, do not generate a @detailmenu in the "Top" node's menu. 318 | # texinfo_no_detailmenu = False 319 | 320 | 321 | # Example configuration for intersphinx: refer to the Python standard library. 322 | intersphinx_mapping = {"https://docs.python.org/": None} 323 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. configurator documentation master file, created by 2 | sphinx-quickstart on Tue May 2 14:29:56 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | API 7 | === 8 | 9 | .. toctree:: 10 | 11 | pydecor 12 | 13 | 14 | .. include:: ../README.rst 15 | 16 | CHANGELOG 17 | ========= 18 | 19 | .. include:: ../CHANGELOG.rst 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | pydecor 2 | ======= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | pydecor 8 | -------------------------------------------------------------------------------- /docs/pydecor.caches.rst: -------------------------------------------------------------------------------- 1 | pydecor.caches module 2 | ===================== 3 | 4 | .. automodule:: pydecor.caches 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pydecor.constants.rst: -------------------------------------------------------------------------------- 1 | pydecor.constants module 2 | ======================== 3 | 4 | .. automodule:: pydecor.constants 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pydecor.decorators.generic.rst: -------------------------------------------------------------------------------- 1 | pydecor.decorators.generic module 2 | ================================= 3 | 4 | .. automodule:: pydecor.decorators.generic 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pydecor.decorators.ready_to_wear.rst: -------------------------------------------------------------------------------- 1 | pydecor.decorators.ready\_to\_wear module 2 | ========================================= 3 | 4 | .. automodule:: pydecor.decorators.ready_to_wear 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pydecor.decorators.rst: -------------------------------------------------------------------------------- 1 | pydecor.decorators package 2 | ========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | 9 | pydecor.decorators.generic 10 | pydecor.decorators.ready_to_wear 11 | 12 | Module contents 13 | --------------- 14 | 15 | .. automodule:: pydecor.decorators 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | -------------------------------------------------------------------------------- /docs/pydecor.functions.rst: -------------------------------------------------------------------------------- 1 | pydecor.functions module 2 | ======================== 3 | 4 | .. automodule:: pydecor.functions 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/pydecor.rst: -------------------------------------------------------------------------------- 1 | pydecor package 2 | =============== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | pydecor.decorators 10 | 11 | Submodules 12 | ---------- 13 | 14 | .. toctree:: 15 | 16 | pydecor.caches 17 | pydecor.constants 18 | pydecor.functions 19 | 20 | Module contents 21 | --------------- 22 | 23 | .. automodule:: pydecor 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | sphinx 3 | sphinx-autodoc-typehints 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # Minimum requirements for the build system to execute. 3 | requires = ["setuptools", "wheel"] # PEP 508 specifications. 4 | 5 | 6 | [tool.black] 7 | # Configuration for the Black autoformatter 8 | line-length = 80 9 | target-version = ['py34'] 10 | exclude = ''' 11 | ( 12 | /( 13 | \.eggs # exclude a few common directories in the 14 | | \.git # root of the project 15 | | \.mypy_cache 16 | | \.venv 17 | | \.vscode 18 | | \.tox 19 | | _build 20 | | build 21 | | dist 22 | | venv 23 | ) 24 | ) 25 | ''' 26 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | # Linting/dev requirements: these are not necessary for Pypy, b/c we don't 4 | # run linting with it, and it's a pain to install things on in CI. 5 | black;python_version>"3.5" and implementation_name=="cpython" 6 | flake8;implementation_name=="cpython" 7 | ipdb;implementation_name=="cpython" 8 | mypy;implementation_name=="cpython" 9 | pylint;implementation_name=="cpython" 10 | 11 | coverage 12 | mock;python_version<"3.0" 13 | pytest>=4.6 14 | pytest-cov;implementation_name=="cpython" 15 | pytest-cov==2.8.1;implementation_name=="pypy" 16 | pytest-cov==2.8.1;python_version<="3.7" 17 | tox 18 | wheel 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dill 2 | six 3 | typing;python_version<"3.5" 4 | -------------------------------------------------------------------------------- /scripts/check_ready_to_distribute.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Prepare to distribute the package.""" 3 | 4 | from subprocess import Popen, PIPE 5 | from sys import argv 6 | 7 | import pydecor as pkg 8 | 9 | 10 | def check_version(version: str) -> None: 11 | """Ensure the version matches the package.""" 12 | assert version == pkg.__version__, "Failed version check." 13 | 14 | 15 | def check_branch() -> None: 16 | """Ensure we are on the master branch.""" 17 | proc = Popen( 18 | ("git", "rev-parse", "--abbrev-ref", "HEAD"), stdout=PIPE, stderr=PIPE 19 | ) 20 | out, err = proc.communicate() 21 | assert proc.returncode == 0, err.decode() 22 | assert out.decode().strip() == "master", "Not on master!" 23 | 24 | 25 | def check_diff() -> None: 26 | """Ensure there is no git diff output with origin/master.""" 27 | proc = Popen(("git", "diff", "origin/master"), stdout=PIPE, stderr=PIPE) 28 | out, err = proc.communicate() 29 | assert proc.returncode == 0, err.decode() 30 | assert out.decode().strip() == "", "There is a diff with origin/master!" 31 | 32 | 33 | def main() -> None: 34 | """Check version, git tag, etc.""" 35 | version = argv[1] 36 | check_version(version) 37 | check_branch() 38 | check_diff() 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | 4 | [bdist_wheel] 5 | python-tag=py3 6 | ; universal=1 7 | 8 | [coverage:run] 9 | branch = True 10 | source = pydecor 11 | 12 | [flake8] 13 | max-line-length = 80 14 | 15 | [mypy] 16 | check_untyped_defs = True 17 | follow_imports = silent 18 | ignore_missing_imports = True 19 | show_column_numbers = True 20 | disallow_untyped_calls = False 21 | disallow_untyped_defs = False 22 | disallow_incomplete_defs = False 23 | disallow_untyped_decorators = True 24 | strict_optional = True 25 | warn_redundant_casts = True 26 | warn_return_any = True 27 | warn_unused_ignores = True 28 | 29 | [mypy-tests.*] 30 | disallow_untyped_decorators = False 31 | 32 | [pydocstyle] 33 | add-ignore = D202, D203, D213, D400, D413 34 | 35 | [tool:pytest] 36 | norecursedirs = .* build dist CVS _darcs {arch} .*egg venv 37 | junit_family=xunit2 38 | junit_xml=.pytest.xml 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | setup module for ihiji_schemas 4 | """ 5 | 6 | import typing as t 7 | from os.path import dirname, join, realpath 8 | from setuptools import setup, find_packages 9 | from sys import version_info 10 | 11 | if version_info < (3, 0): 12 | from codecs import open 13 | 14 | 15 | fdir = dirname(realpath(__file__)) 16 | 17 | 18 | NAME = "pydecor" 19 | URL = "https://github.com/mplanchard/pydecor" 20 | AUTHOR = "Matthew Planchard" 21 | EMAIL = "msplanchard@gmail.com" 22 | 23 | 24 | SHORT_DESC = "Easy peasy Python decorators" 25 | 26 | with open(join(fdir, "README.rst"), encoding="utf-8") as readmefile: 27 | LONG_DESC = readmefile.read() 28 | 29 | 30 | KEYWORDS = ["decorators", "python3"] 31 | 32 | 33 | PACKAGE_DEPENDENCIES = [] 34 | SETUP_DEPENDENCIES: t.List[str] = [] 35 | TEST_DEPENDENCIES = [] 36 | 37 | ENTRY_POINTS: dict = {} 38 | 39 | 40 | with open(join(fdir, "requirements.txt")) as reqfile: 41 | for ln in reqfile: 42 | if not ln.startswith("#"): 43 | PACKAGE_DEPENDENCIES.append(ln.strip()) 44 | 45 | 46 | with open(join(fdir, "requirements-dev.txt")) as reqfile: 47 | for ln in reqfile: 48 | if not ln.startswith("#") and not ln.startswith("-r"): 49 | TEST_DEPENDENCIES.append(ln.strip()) 50 | 51 | 52 | EXTRAS_DEPENDENCIES = {"dev": TEST_DEPENDENCIES} 53 | 54 | 55 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers for all 56 | # available setup classifiers 57 | CLASSIFIERS = [ 58 | # 'Development Status :: 1 - Planning', 59 | # 'Development Status :: 2 - Pre-Alpha', 60 | # 'Development Status :: 3 - Alpha', 61 | # 'Development Status :: 4 - Beta', 62 | "Development Status :: 5 - Production/Stable", 63 | # 'Development Status :: 6 - Mature', 64 | # 'Framework :: AsyncIO', 65 | # 'Framework :: Flask', 66 | # 'Framework :: Sphinx', 67 | # 'Environment :: Web Environment', 68 | "Intended Audience :: Developers", 69 | # 'Intended Audience :: End Users/Desktop', 70 | # 'Intended Audience :: Science/Research', 71 | # 'Intended Audience :: System Administrators', 72 | # 'License :: Other/Proprietary License', 73 | # 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 74 | "License :: OSI Approved :: MIT License", 75 | "Natural Language :: English", 76 | # 'Operating System :: MacOS :: MacOS X 77 | "Operating System :: POSIX :: Linux", 78 | # "Programming Language :: Python", 79 | "Programming Language :: Python :: 3 :: Only", 80 | "Programming Language :: Python :: 3.6", 81 | "Programming Language :: Python :: 3.7", 82 | "Programming Language :: Python :: 3.8", 83 | "Programming Language :: Python :: Implementation :: PyPy", 84 | ] 85 | 86 | 87 | __version__ = "0.0.0" 88 | 89 | with open(join(fdir, "src/{0}/_version.py".format(NAME))) as version_file: 90 | for line in version_file: 91 | # This will populate the __version__ and __version_info__ variables 92 | if line.startswith("__"): 93 | exec(line) 94 | 95 | setup( 96 | name=NAME, 97 | version=__version__, 98 | description=SHORT_DESC, 99 | long_description=LONG_DESC, 100 | url=URL, 101 | author=AUTHOR, 102 | author_email=EMAIL, 103 | classifiers=CLASSIFIERS, 104 | keywords=KEYWORDS, 105 | package_dir={"": "src"}, 106 | packages=find_packages(where="src"), 107 | python_requires=">=3.6", 108 | install_requires=PACKAGE_DEPENDENCIES, 109 | setup_requires=SETUP_DEPENDENCIES, 110 | tests_require=TEST_DEPENDENCIES, 111 | extras_require=EXTRAS_DEPENDENCIES, 112 | ) 113 | -------------------------------------------------------------------------------- /src/pydecor/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """All decorators are exposed directly here. 3 | 4 | In addition, decorators may be imported from `pydecor.decorators`. 5 | 6 | Non-decorator utilities like caches and functions are contained 7 | in their own modules and not exposed here. 8 | """ 9 | 10 | from ._version import __version__, __version_info__ # noqa 11 | from . import decorators 12 | from .decorators import * # noqa 13 | 14 | __all__ = decorators.__all__ 15 | -------------------------------------------------------------------------------- /src/pydecor/_memoization.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Required functionality for the memoization function 4 | """ 5 | 6 | __all__ = ("convert_to_hashable", "hashable") 7 | 8 | import dill as pickle 9 | 10 | 11 | def convert_to_hashable(args, kwargs): 12 | """Return args and kwargs as a hashable tuple""" 13 | return hashable(args), hashable(kwargs) 14 | 15 | 16 | def hashable(item): 17 | """Get return a hashable version of an item 18 | 19 | If the item is natively hashable, return the item itself. If 20 | it is not, return it dumped to a pickle string. 21 | """ 22 | try: 23 | hash(item) 24 | except TypeError: 25 | item = pickle.dumps(item) 26 | return item 27 | -------------------------------------------------------------------------------- /src/pydecor/_version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | version.py module 4 | 5 | The version set here will be automatically incorporated into setup.py 6 | and also set as the __version__ attribute for the package. 7 | """ 8 | 9 | from __future__ import absolute_import, unicode_literals 10 | 11 | __version_info__ = (2, 0, 1) 12 | __version__ = ".".join([str(ver) for ver in __version_info__]) 13 | -------------------------------------------------------------------------------- /src/pydecor/caches.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Caches for memoization 4 | """ 5 | 6 | from __future__ import absolute_import, unicode_literals 7 | 8 | 9 | __all__ = ("LRUCache", "FIFOCache", "TimedCache") 10 | 11 | 12 | from collections import OrderedDict 13 | from time import time 14 | 15 | 16 | class LRUCache(OrderedDict): 17 | """Self-pruning cache using an LRU strategy. 18 | 19 | If instantiated with a ``max_size`` other than ``0``, will 20 | automatically prune the least-recently-used (LRU) key/value 21 | pair when inserting an item after reaching the specified size. 22 | 23 | An item is considered to be "used" when it is inserted or 24 | accessed, at which point its position in recently used 25 | queue is updated to the most recent. 26 | 27 | Supports all standard dictionary methods. 28 | 29 | :param int max_size: maximum number of entries to save 30 | before pruning 31 | """ 32 | 33 | def __init__(self, max_size=0, *args, **kwargs): 34 | super(LRUCache, self).__init__(*args, **kwargs) 35 | self._max_size = max_size 36 | 37 | def __getitem__(self, key, **kwargs): 38 | value = OrderedDict.__getitem__(self, key) 39 | del self[key] 40 | OrderedDict.__setitem__(self, key, value, **kwargs) # type: ignore 41 | return value 42 | 43 | def __setitem__(self, key, value, **kwargs): 44 | if key in self: 45 | del self[key] 46 | OrderedDict.__setitem__(self, key, value, **kwargs) # type: ignore 47 | if self._max_size and len(self) > self._max_size: 48 | self.popitem(last=False) 49 | 50 | 51 | class FIFOCache(OrderedDict): 52 | """Self-pruning cache using a FIFO strategy 53 | 54 | If instantiated with a ``max_size`` other than ``0``, will 55 | automatically prune the least-recently-inserted key/value 56 | pair when inserting an item after reaching the specified 57 | size. 58 | 59 | Supports all standard dictionary methods. 60 | 61 | :param int max_size: maximum number of entries to save 62 | before pruning 63 | """ 64 | 65 | def __init__(self, max_size=0, *args, **kwargs): 66 | super(FIFOCache, self).__init__(*args, **kwargs) 67 | self._max_size = max_size 68 | 69 | def __setitem__(self, key, value, **kwargs): 70 | OrderedDict.__setitem__(self, key, value) 71 | if self._max_size and len(self) > self._max_size: 72 | self.popitem(last=False) 73 | 74 | 75 | class TimedCache(dict): 76 | """Self-pruning cache whose entries can be set to expire 77 | 78 | If instantiated with a ``max_age`` other than ``0``, will 79 | consider entries older than the specified age to be invalid, 80 | removing them from the cache upon an attempt to access them 81 | and returning as though they do not exist. 82 | 83 | Supports all standard dictionary methods. 84 | 85 | :param int max_age: age in seconds beyond which entries 86 | should be considered invalid. The default is 0, which 87 | means that entries should be stored forever. 88 | """ 89 | 90 | def __init__(self, max_age=0, *args, **kwargs): 91 | super(TimedCache, self).__init__(*args, **kwargs) 92 | self._max_age = max_age 93 | 94 | def __getitem__(self, key): 95 | value, last_time = dict.__getitem__(self, key) 96 | now = time() 97 | if self._max_age and now - last_time > self._max_age: 98 | del self[key] 99 | raise KeyError(key) 100 | else: 101 | return value 102 | 103 | def __setitem__(self, key, value): 104 | now = time() 105 | dict.__setitem__(self, key, (value, now)) 106 | 107 | def __contains__(self, key): 108 | try: 109 | self.__getitem__(key) 110 | except KeyError: 111 | return False 112 | return True 113 | -------------------------------------------------------------------------------- /src/pydecor/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | This rather skinny module just contains constants for use elsewhere. 4 | """ 5 | 6 | LOG_CALL_FMT_STR = "{name}(*{args}, **{kwargs}) -> {result}" 7 | -------------------------------------------------------------------------------- /src/pydecor/decorators/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Public interface for decorators sub-package 4 | """ 5 | 6 | __all__ = ( 7 | "after", 8 | "before", 9 | "construct_decorator", 10 | "decorate", 11 | "export", 12 | "instead", 13 | "Decorated", 14 | "intercept", 15 | "log_call", 16 | "memoize", 17 | ) 18 | 19 | from .generic import ( 20 | after, 21 | before, 22 | construct_decorator, 23 | decorate, 24 | Decorated, 25 | instead, 26 | ) 27 | from .ready_to_wear import ( 28 | export, 29 | intercept, 30 | log_call, 31 | memoize, 32 | ) 33 | -------------------------------------------------------------------------------- /src/pydecor/decorators/_utility.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Private interface utilities for PyDecor 4 | """ 5 | 6 | from __future__ import absolute_import, unicode_literals 7 | 8 | 9 | __all__ = ("ClassWrapper",) 10 | 11 | 12 | from functools import partial 13 | from inspect import isclass 14 | from logging import getLogger 15 | from sys import version_info 16 | 17 | from inspect import isfunction, ismethod 18 | 19 | 20 | log = getLogger(__name__) 21 | 22 | 23 | PY2 = version_info < (3, 0) 24 | 25 | 26 | def get_fn_args(decorated, args): 27 | """Strip "self" and "cls" variables from args. 28 | 29 | For now, this avoids assuming that the "self" variable is called 30 | "self" on instance methods, although if this winds up being 31 | problematic for edge use cases, I might move to that model 32 | and make it clear that instance methods for which "self" 33 | isn't called "self" won't have their "self" argument stripped. 34 | """ 35 | fn_args = args 36 | decor_name = ( 37 | decorated.__name__ if hasattr(decorated, "__name__") else str(decorated) 38 | ) 39 | 40 | # Check if the wrapped function is an attribute on args[0] 41 | if args and decor_name in dir(args[0]): 42 | 43 | # Check if args[0] is a class reference 44 | if isclass(args[0]): 45 | # Check if the wrapped function is a classmethod on the 46 | # class' class dict 47 | if type(args[0].__dict__[decor_name]) is classmethod: 48 | # The first argument is a reference to the class 49 | # containing the decorated function 50 | fn_args = args[1:] 51 | 52 | # Check if args[0] is a function defined on the class 53 | else: 54 | cls_dict = args[0].__class__.__dict__ 55 | 56 | # Check if the wrapped function is a function on the class' 57 | # class dict (instance methods are functions on the class 58 | # dict, while classmethods and staticmethods are their own 59 | # types) 60 | if decor_name in cls_dict and isfunction(cls_dict[decor_name]): 61 | # The first argument is probably a "self" variable 62 | fn_args = args 63 | 64 | return fn_args 65 | 66 | 67 | class ClassWrapper(object): 68 | """A generic class wrapper for decorating class functions/methods 69 | 70 | To be used in the context of a decorator that should percolate 71 | down to methods if used at the class level. 72 | 73 | Intended usage is to create a **new** class, for example by 74 | using the ``type()`` function, in which the ``wrapped`` 75 | class attribute is replaced by a reference to the class 76 | being wrapped 77 | 78 | Usage: 79 | 80 | .. code:: python 81 | 82 | 83 | def meth_decorator(meth): 84 | '''The decorator that should be applied to class methods''' 85 | 86 | def wrapper(*args, **kwargs): 87 | print('performing wrapper tasks') 88 | func(*args, **kwargs) 89 | print('finished wrapper tasks') 90 | 91 | return wrapper 92 | 93 | 94 | def class_decorator(cls): 95 | '''Applies meth_decorator to class methods/functions''' 96 | wrapper = ClassWrapper.wrap(cls, meth_decorator) 97 | return wrapper 98 | 99 | 100 | @class_decorator 101 | class DecoratedClass: 102 | 103 | def automatically_decorated_method(self): 104 | pass 105 | 106 | """ 107 | 108 | __wrapped__ = None 109 | __decorator__ = None 110 | __decoropts__ = None 111 | 112 | def __init__(self, *args, **kwargs): 113 | self.__wrapped__ = self.__wrapped__(*args, **kwargs) # type: ignore 114 | 115 | def __getattribute__(self, item): 116 | 117 | if item in ("__wrapped__", "__decorator__", "__decoropts__"): 118 | return object.__getattribute__(self, item) 119 | 120 | wrapped = object.__getattribute__(self, "__wrapped__") 121 | attr = getattr(wrapped, item) 122 | 123 | if attr is None: 124 | raise AttributeError 125 | 126 | if ismethod(attr) or isfunction(attr): 127 | cls = object.__getattribute__(self, "__class__") 128 | decoropts = object.__getattribute__(cls, "__decoropts__") 129 | decor = object.__getattribute__(cls, "__decorator__") 130 | 131 | if decoropts["instance_methods_only"]: 132 | cls_attr = object.__getattribute__(cls, item) 133 | if type(cls_attr) is classmethod: 134 | return attr 135 | if type(cls_attr) is staticmethod: 136 | return attr 137 | 138 | return decor(attr) 139 | 140 | return attr 141 | 142 | @classmethod 143 | def _get_class_attrs(cls, wrapped, decorator, instance_methods_only): 144 | """Get the attrs for a new class instance 145 | 146 | :param type wrapped: a reference to the class to be wrapped 147 | :param Union[FunctionType,MethodType,type] decorator: 148 | the decorator to apply to the 149 | functions and methods of the wrapped class 150 | """ 151 | attrs = {} 152 | 153 | for k, v in wrapped.__dict__.items(): 154 | if not k.startswith("__"): 155 | if not instance_methods_only: 156 | if type(v) is classmethod: 157 | attrs[k] = partial(decorator(v.__func__), wrapped) 158 | elif type(v) is staticmethod: 159 | if PY2: 160 | attrs[k] = staticmethod( 161 | partial(decorator(v.__func__)) 162 | ) 163 | else: 164 | attrs[k] = decorator(v.__func__) 165 | else: 166 | attrs[k] = v 167 | else: 168 | attrs[k] = v 169 | 170 | attrs.update( 171 | { 172 | "__wrapped__": wrapped, 173 | "__decorator__": decorator, 174 | "__decoropts__": { # type: ignore 175 | "instance_methods_only": instance_methods_only 176 | }, 177 | } 178 | ) 179 | 180 | return attrs 181 | 182 | @classmethod 183 | def wrap(cls, wrapped, decorator, instance_methods_only=False): 184 | """Return a new class wrapping the passed class 185 | 186 | :param type wrapped: a reference to the class to be wrapped 187 | :param Union[FunctionType,MethodType,type] decorator: 188 | the decorator to apply to the 189 | functions and methods of the wrapped class 190 | """ 191 | name = "Wrapped{}".format(wrapped.__name__) 192 | if PY2: 193 | name = str(name) 194 | 195 | return type( 196 | name, 197 | (cls,), 198 | # {'__decorator__': decorator} 199 | cls._get_class_attrs(wrapped, decorator, instance_methods_only), 200 | ) 201 | -------------------------------------------------------------------------------- /src/pydecor/decorators/_visibility.py: -------------------------------------------------------------------------------- 1 | # EMACS settings: -*- coding: utf-8 -*- 2 | """ 3 | Decorators controlling visibility of entities in a Python module. 4 | """ 5 | 6 | __all__ = ("export",) 7 | __api__ = __all__ 8 | 9 | import sys 10 | import typing as t 11 | 12 | 13 | T = t.TypeVar("T", bound=t.Callable) 14 | 15 | 16 | def export(entity: T) -> T: 17 | """Register the given function or class as publicly accessible in a module. 18 | 19 | Creates or updates the `__all__` attribute in the module in which the 20 | decorated entity is defined to include the name of the decorated 21 | entity. 22 | 23 | Example: 24 | 25 | `to_export.py`: 26 | 27 | .. code:: python 28 | 29 | from pydecor import export 30 | 31 | @export 32 | def exported(): 33 | pass 34 | 35 | def not_exported(): 36 | pass 37 | 38 | 39 | `another_file.py` 40 | 41 | .. code:: python 42 | 43 | from .to_export import * 44 | 45 | assert "exported" in globals() 46 | assert "not_exported" not in globals() 47 | 48 | 49 | :param Union[Type, types.FunctionType] entity: the function or class 50 | to include in `__all__` 51 | """ 52 | # * Based on an idea by Duncan Booth: 53 | # http://groups.google.com/group/comp.lang.python/msg/11cbb03e09611b8a 54 | # * Improved via a suggestion by Dave Angel: 55 | # http://groups.google.com/group/comp.lang.python/msg/3d400fb22d8a42e1 56 | 57 | if not hasattr(entity, "__module__"): 58 | raise TypeError( 59 | ( 60 | "{entity} has no __module__ attribute. Please ensure it is " 61 | "a top-level function or class reference defined in a module." 62 | ).format(entity=entity) 63 | ) 64 | 65 | if hasattr(entity, "__qualname__"): 66 | if any(i in entity.__qualname__ for i in (".", "", "")): 67 | raise TypeError( 68 | "Only named top-level functions and classes may be exported, " 69 | "not {}".format(entity) 70 | ) 71 | 72 | if not hasattr(entity, "__name__") or entity.__name__ == "": 73 | raise TypeError( 74 | "Entity must be a named top-level funcion or class, not {}".format( 75 | type(entity) 76 | ) 77 | ) 78 | 79 | try: 80 | module = sys.modules[entity.__module__] 81 | except KeyError: 82 | raise ValueError( 83 | ( 84 | "Module {} is not present in sys.modules. Please ensure " 85 | "it is in the import path before calling export()." 86 | ).format(entity.__module__) 87 | ) 88 | if hasattr(module, "__all__"): 89 | if entity.__name__ not in module.__all__: # type: ignore 90 | module.__all__ = module.__all__.__class__( # type: ignore 91 | (*module.__all__, entity.__name__) # type: ignore 92 | ) 93 | else: 94 | module.__all__ = [entity.__name__] # type: ignore 95 | 96 | return entity 97 | -------------------------------------------------------------------------------- /src/pydecor/decorators/generic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Generic decorators help you to easily create your own decorators with simple 4 | ``@before``, ``@after``, and ``@instead`` decorators that call a function 5 | as specified in the execution order of the decorated callable. 6 | 7 | Additionally the ``@decorate`` decorator allows you to combine the above three 8 | generics in one call, and the ``construct_decorator`` function returns a 9 | ready-to-use decorator implementing any or all of the above three generics, 10 | which you can then assign to a variable name and use as desired. 11 | """ 12 | 13 | from __future__ import absolute_import, unicode_literals 14 | 15 | 16 | __all__ = ( 17 | "after", 18 | "before", 19 | "construct_decorator", 20 | "decorate", 21 | "instead", 22 | "Decorated", 23 | "DecoratorType", 24 | ) 25 | 26 | 27 | import typing as t 28 | from inspect import isclass 29 | from logging import getLogger 30 | from functools import partial, wraps 31 | from types import FunctionType, MethodType 32 | from typing import Union 33 | 34 | from ._utility import ClassWrapper, get_fn_args 35 | 36 | 37 | log = getLogger(__name__) 38 | 39 | 40 | DecoratorType = Union[FunctionType, MethodType, type] 41 | 42 | 43 | class Decorated(object): 44 | """A representation of a decorated class. 45 | 46 | This user-immutable object provides information about the decorated 47 | class, method, or function. The decorated callable can be called 48 | by directly calling the ``Decorated`` instance, or via the 49 | ``wrapped`` instance attribute. 50 | 51 | The example below illustrates direct instantiation of the 52 | ``Decorated`` class, but generally you will only deal with 53 | instances of this class when they are passed to the functions 54 | specified on generic decorators. 55 | 56 | .. code:: python 57 | 58 | from pydecor import Decorated 59 | 60 | def some_function(*args, **kwargs): 61 | return 'foo' 62 | 63 | decorated = Decorated(some_function, ('a', 'b'), {'c': 'c'}) 64 | 65 | assert decorated.wrapped.__name__ == some_function.__name__ 66 | assert decorated.args == ('a', 'b') 67 | assert decorated.kwargs == {'c': 'c'} 68 | assert decorated.result is None # has not yet been called 69 | 70 | res = decorated(decorated.args, decorated.kwargs) 71 | 72 | assert 'foo' == res == decorated.result 73 | 74 | .. note:: 75 | 76 | identity tests ``decorated.wrapped is some_decorated_function`` 77 | will not work on the ``wrapped`` attribute of a ``Decorated`` 78 | instance, because internally the wrapped callable is wrapped 79 | in a method that ensures that ``Decorated.result`` is set 80 | whenever the callable is called. It is wrapped using 81 | ``functools.wraps``, so attributes like ``__name__``, 82 | ``__doc__``, ``__module__``, etc. should still be the 83 | same as on an actual reference. 84 | 85 | If you need to access a real reference to the wrapped 86 | function for any reason, you can do so by accessing 87 | the ``__wrapped__`` property, on ``wrapped``, which is 88 | set by ``functools.wraps``, e.g. 89 | ``decorated.wrapped.__wrapped__``. 90 | 91 | :param wrapped: a reference to the wrapped callable. Calling the 92 | wrapped callable via this reference will set the ``result`` 93 | attribute. 94 | :param args: a tuple of arguments with which the decorated function 95 | was called 96 | :param kwargs: a dict of arguments with which the decorated function 97 | was called 98 | :param result: either ``None`` if the wrapped callable has not yet 99 | been called or the result of that call 100 | """ 101 | 102 | __slots__ = ("args", "kwargs", "wrapped", "result") 103 | 104 | args: tuple 105 | kwargs: dict 106 | wrapped: t.Callable 107 | result: t.Optional[t.Any] 108 | 109 | def __init__(self, wrapped, args, kwargs, result=None): 110 | """Instantiate a Decorated object 111 | 112 | :param callable wrapped: the callable object being wrapped 113 | :param tuple args: args with which the callable was called 114 | :param dict kwargs: keyword arguments with which the callable 115 | was called 116 | :param 117 | """ 118 | sup = super(Decorated, self) 119 | sup.__setattr__("args", get_fn_args(wrapped, args)) 120 | sup.__setattr__("kwargs", kwargs) 121 | sup.__setattr__("wrapped", self._sets_results(wrapped)) 122 | sup.__setattr__("result", result) 123 | 124 | def __str__(self): 125 | """Return a nice string of self""" 126 | if hasattr(self.wrapped, "__name__"): 127 | name = self.wrapped.__name__ 128 | else: 129 | name = str(self.wrapped) 130 | return "".format(name, self.args, self.kwargs) 131 | 132 | def __call__(self, *args, **kwargs): 133 | """Call the function the specified arguments. 134 | 135 | Also set ``self.result`` 136 | """ 137 | return self.wrapped(*args, **kwargs) 138 | 139 | def __setattr__(self, key, value): 140 | """Disallow attribute setting""" 141 | raise AttributeError( 142 | 'Cannot set "{}" because {} is immutable'.format(key, self) 143 | ) 144 | 145 | def _sets_results(self, wrapped): 146 | """Ensure that calling ``wrapped()`` sets the result attr 147 | 148 | :param callable wrapped: the wrapped function, class, or method 149 | """ 150 | 151 | @wraps(wrapped) 152 | def wrapped_wrapped(*args, **kwargs): 153 | """Set self.result after calling wrapped""" 154 | res = wrapped(*args, **kwargs) 155 | super(Decorated, self).__setattr__("result", res) 156 | return res 157 | 158 | return wrapped_wrapped 159 | 160 | 161 | def before( 162 | func, implicit_method_decoration=True, instance_methods_only=False, **extras 163 | ): 164 | """Specify a callable to be run before the decorated resource. 165 | 166 | A callable provided to this decorator will be called 167 | any time the decorated function is executed, immediately _before_ 168 | its execution. 169 | 170 | The callable is expected to either return ``None`` or to return 171 | a tuple and dict with which to replace the arguments to the 172 | decorated function. 173 | 174 | The callable is called with an instance of :any:`Decorated` as 175 | its first positional argument. Any kwargs provided to `before()` 176 | are also passed to the callable. 177 | 178 | :param Callable func: 179 | a callable to run before the decorated function. It is called 180 | with an instance of :any:`Decorated` as its first argument. In 181 | addition, any keyword arguments provided to `before` are passed 182 | through to the callable. 183 | 184 | :param bool implicit_method_decoration: 185 | (default True) if True, decorating a class implies decorating 186 | all of its methods. Otherwise, the decorator will be called 187 | when the class is instantiated. 188 | 189 | :param bool instance_methods_only: 190 | (default False) if True, decorating a class implies decorating 191 | only its instance methods (not ``classmethod``- or 192 | ``staticmethod``- decorated methods) 193 | 194 | :param **dict extras: 195 | any extra keyword arguments supplied to the decoration call, 196 | which will be passed directly to the provided callable. 197 | 198 | :return: the decorated function/method/class 199 | :rtype: Union[FunctionType,MethodType,type] 200 | """ 201 | 202 | def decorator(decorated): 203 | """Return a decorated function.""" 204 | 205 | def wrapper(*args, **kwargs): 206 | """Call the before and decorated functions.""" 207 | fn = func 208 | 209 | decor = Decorated(decorated, args, kwargs) 210 | fret = fn(decor, **extras) 211 | 212 | if fret is not None: 213 | args, kwargs = fret 214 | 215 | ret = decor(*args, **kwargs) 216 | return ret 217 | 218 | if implicit_method_decoration and isclass(decorated): 219 | return ClassWrapper.wrap( 220 | decorated, 221 | decorator, 222 | instance_methods_only=instance_methods_only, 223 | ) 224 | 225 | # Equivalent to @wraps(decorated) on `wrapper` 226 | return wraps(decorated)(wrapper) 227 | 228 | return decorator 229 | 230 | 231 | def after( 232 | func, implicit_method_decoration=True, instance_methods_only=False, **extras 233 | ): 234 | """Specify a callable to be run after the decorated resource. 235 | 236 | A callable provided to this decorator will be called 237 | any time the decorated function is executed, immediately _after_ 238 | its execution. 239 | 240 | If the callable returns a value, that value will replace the 241 | return value of the decorated function. 242 | 243 | The callable is called with an instance of :any:`Decorated` as 244 | its first positional argument. Any kwargs provided to `after()` 245 | are also passed to the callable. 246 | 247 | :param Callable func: 248 | a callable to run after the decorated function. It is called 249 | with an instance of :any:`Decorated` as its first argument. In 250 | addition, any keyword arguments provided to `before` are passed 251 | through to the callable. 252 | 253 | :param bool implicit_method_decoration: 254 | (default True) if True, decorating a class implies decorating 255 | all of its methods. Otherwise, the decorator will be called 256 | when the class is instantiated. 257 | 258 | :param bool instance_methods_only: 259 | (default False) if True, decorating a class implies decorating 260 | only its instance methods (not ``classmethod``- or 261 | ``staticmethod``- decorated methods) 262 | 263 | :param **dict extras: 264 | any extra keyword arguments supplied to the decoration call, 265 | which will be passed directly to the provided callable. 266 | 267 | :return: the decorated function/method/class 268 | :rtype: Union[FunctionType,MethodType,type] 269 | """ 270 | 271 | def decorator(decorated): 272 | """The function that returns a replacement for the original""" 273 | 274 | def wrapper(*args, **kwargs): 275 | """The function that replaces the decorated one""" 276 | decor = Decorated(decorated, args, kwargs) 277 | orig_ret = decor(*args, **kwargs) 278 | fret = func(decor, **extras) 279 | 280 | if fret is not None: 281 | return fret 282 | 283 | return orig_ret 284 | 285 | if implicit_method_decoration and isclass(decorated): 286 | return ClassWrapper.wrap( 287 | decorated, 288 | decorator, 289 | instance_methods_only=instance_methods_only, 290 | ) 291 | 292 | # Equivalent to @wraps(decorated) on `wrapper` 293 | return wraps(decorated)(wrapper) 294 | 295 | return decorator 296 | 297 | 298 | def instead( 299 | func, implicit_method_decoration=True, instance_methods_only=False, **extras 300 | ): 301 | """Specify a callable to be run in the place of the decorated resource. 302 | 303 | A callable provided to this decorator will be called any time the 304 | decorated function is executed. The decorated function **will not** 305 | be called unless the provided callable calls it. 306 | 307 | Whatever the provided callable returns is the return value of the 308 | decorated function. 309 | 310 | The callable is called with an instance of :any:`Decorated` as 311 | its first positional argument. Any kwargs provided to `instead()` 312 | are also passed to the callable. 313 | 314 | :param Callable func: 315 | a callable to run in place of the decorated function. It is called 316 | with an instance of :any:`Decorated` as its first argument. In 317 | addition, any keyword arguments provided to `before` are passed 318 | through to the callable. 319 | 320 | :param bool implicit_method_decoration: 321 | (default True) if True, decorating a class implies decorating 322 | all of its methods. Otherwise, the decorator will be called 323 | when the class is instantiated. 324 | 325 | :param bool instance_methods_only: 326 | (default False) if True, decorating a class implies decorating 327 | only its instance methods (not ``classmethod``- or 328 | ``staticmethod``- decorated methods) 329 | 330 | :param **dict extras: 331 | any extra keyword arguments supplied to the decoration call, 332 | which will be passed directly to the provided callable. 333 | 334 | :return: the decorated function/method/class 335 | :rtype: Union[FunctionType,MethodType,type] 336 | """ 337 | 338 | def decorator(decorated): 339 | """The function that returns a replacement for the original""" 340 | 341 | def wrapper(*args, **kwargs): 342 | """The function that replaces the decorated one""" 343 | decor = Decorated(decorated, args, kwargs) 344 | return func(decor, **extras) 345 | 346 | if implicit_method_decoration and isclass(decorated): 347 | return ClassWrapper.wrap( 348 | decorated, 349 | decorator, 350 | instance_methods_only=instance_methods_only, 351 | ) 352 | 353 | # Equivalent to @wraps(decorated) on `wrapper` 354 | return wraps(decorated)(wrapper) 355 | 356 | return decorator 357 | 358 | 359 | def decorate( 360 | before=None, 361 | after=None, 362 | instead=None, 363 | before_kwargs=None, 364 | after_kwargs=None, 365 | instead_kwargs=None, 366 | implicit_method_decoration=True, 367 | instance_methods_only=False, 368 | **extras 369 | ): 370 | """A decorator that combines before, after, and instead decoration. 371 | 372 | The ``before``, ``after``, and ``instead`` decorators are all 373 | stackable, but this decorator provides a straightforward interface 374 | for combining them so that you don't have to worry about 375 | decorator precedence. 376 | 377 | The order the callables are executed in is: 378 | 379 | * before 380 | * instead / wrapped function 381 | * after 382 | 383 | This decorator takes three optional keyword arguments, ``before``, 384 | ``after``, and ``instead``, each of which may be any callable that 385 | might be provided to those decorators. 386 | 387 | The provided callables will be invoked with the same default 388 | call signature as for the individual decorators. Call signatures 389 | and other options may be adjusted by passing ``before_kwargs``, 390 | ``after_kwargs``, and ``instead_kwargs`` dicts to this decorator, 391 | which will be directly unpacked into the invocation of the 392 | individual decorators. 393 | 394 | The ``instance_methods_only`` and ``implicit_method_decoration`` 395 | options may only be changed globally as arguments to this 396 | decorator, in order to avoid confusion between which decorator 397 | is being applied to which methods. 398 | 399 | You may specify decorator-specific extras in the various ``opts`` 400 | dictionaries. These will be passed to the provided callables in 401 | addition to any keyword arguments supplied to the call to 402 | `decorate()`. If there is a naming conflict, the latter override 403 | the former. 404 | 405 | :param Callable before: 406 | a callable to run before the decorated function. It is called 407 | with an instance of :any:`Decorated` as its first argument. In 408 | addition, any keyword arguments provided to `before` are passed 409 | through to the callable. 410 | 411 | :param Callable after: 412 | a callable to run after the decorated function. It is called 413 | with an instance of :any:`Decorated` as its first argument. In 414 | addition, any keyword arguments provided to `before` are passed 415 | through to the callable. 416 | 417 | :param Callable instead: 418 | a callable to run in place of the decorated function. It is called 419 | with an instance of :any:`Decorated` as its first argument. In 420 | addition, any keyword arguments provided to `before` are passed 421 | through to the callable. 422 | 423 | :param dict before_kwargs: 424 | a dictionary of keyword arguments to pass to the ``before`` 425 | decorator. See :any:`before` for supported options. 426 | 427 | :param dict after_kwargs: 428 | a dictionary of keyword arguments to pass to the ``after`` 429 | decorator. See :any:`after` for supported options. 430 | 431 | :param dict instead_kwargs: 432 | a dictionary of keyword arguments to pass to the ``instead`` 433 | decorator. See :any:`instead` for supported options 434 | 435 | :param bool implicit_method_decoration: 436 | (default True) if True, decorating a class implies decorating 437 | all of its methods. This value overrides any values set in 438 | the various ``opts`` dictionaries. If False, the decorator(s) 439 | will be called when the class is instantiated. 440 | 441 | :param bool instance_methods_only: 442 | (default False) if True, decorating a class implies decorating 443 | only its instance methods (not ``classmethod``- or 444 | ``staticmethod``- decorated methods). This value overrides 445 | any values set in the various ``opts`` dictionaries. 446 | 447 | :param **dict extras: 448 | any extra keyword arguments supplied to the decoration call. 449 | These will be passed directly to the `before`, `after`, 450 | and/or `instead` callables. 451 | 452 | :return: the decorated function/method/class 453 | :rtype: Union[FunctionType,MethodType,type] 454 | """ 455 | 456 | if all(arg is None for arg in (before, after, instead)): 457 | raise ValueError( 458 | 'At least one of "before," "after," or "instead" must be provided' 459 | ) 460 | 461 | my_before = before 462 | my_after = after 463 | my_instead = instead 464 | 465 | before_kwargs = before_kwargs or {} 466 | after_kwargs = after_kwargs or {} 467 | instead_kwargs = instead_kwargs or {} 468 | 469 | for opts in (before_kwargs, after_kwargs, instead_kwargs): 470 | # Disallow mixing of class-level functionality 471 | opts["implicit_method_decoration"] = implicit_method_decoration 472 | opts["instance_methods_only"] = instance_methods_only 473 | 474 | def decorator(decorated): 475 | 476 | wrapped = decorated 477 | 478 | if my_instead is not None: 479 | 480 | global instead 481 | wrapped = instead(my_instead, **{**instead_kwargs, **extras})( 482 | wrapped 483 | ) 484 | 485 | if my_before is not None: 486 | 487 | global before 488 | wrapped = before(my_before, **{**before_kwargs, **extras})(wrapped) 489 | 490 | if my_after is not None: 491 | 492 | global after 493 | wrapped = after(my_after, **{**after_kwargs, **extras})(wrapped) 494 | 495 | def wrapper(*args, **kwargs): 496 | 497 | return wrapped(*args, **kwargs) 498 | 499 | if implicit_method_decoration and isclass(wrapped): 500 | return ClassWrapper.wrap( 501 | decorated, 502 | decorator, 503 | instance_methods_only=instance_methods_only, 504 | ) 505 | 506 | return wraps(decorated)(wrapper) 507 | 508 | return decorator 509 | 510 | 511 | def construct_decorator( 512 | before=None, 513 | after=None, 514 | instead=None, 515 | before_kwargs=None, 516 | after_kwargs=None, 517 | instead_kwargs=None, 518 | implicit_method_decoration=True, 519 | instance_methods_only=False, 520 | **extras 521 | ): 522 | """Return a custom decorator. 523 | 524 | Options are all the same as for :any:`decorate` and are, in fact, 525 | passed directly to it. 526 | 527 | Once the decorator has been created, it can be used like any 528 | other decorators in this module. Passing extra keyword arguments 529 | during the decoration call will pass those extras to the 530 | provided callables, even if some default extras were already set 531 | when creating the decorator. 532 | 533 | :return: a decorator that can be used to decorate functions, 534 | classes, or methods 535 | :rtype: FunctionType 536 | """ 537 | return partial( 538 | decorate, 539 | before=before, 540 | after=after, 541 | instead=instead, 542 | before_kwargs=before_kwargs, 543 | after_kwargs=after_kwargs, 544 | instead_kwargs=instead_kwargs, 545 | implicit_method_decoration=implicit_method_decoration, 546 | instance_methods_only=instance_methods_only, 547 | **extras 548 | ) 549 | -------------------------------------------------------------------------------- /src/pydecor/decorators/ready_to_wear.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Prête-à-porte (ready-to-wear) decorators are ready to be used immediately 4 | in your codebase, minimal thinking required. 5 | """ 6 | 7 | from __future__ import absolute_import, unicode_literals 8 | 9 | 10 | __all__ = ("export", "intercept", "log_call", "memoize") 11 | 12 | 13 | from logging import getLogger 14 | 15 | from pydecor import functions 16 | from pydecor.constants import LOG_CALL_FMT_STR 17 | from pydecor.caches import LRUCache 18 | 19 | from .generic import instead, after 20 | from ._visibility import export 21 | 22 | 23 | log = getLogger(__name__) 24 | 25 | 26 | def intercept( 27 | catch=Exception, 28 | reraise=None, 29 | handler=None, 30 | err_msg=None, 31 | include_context=False, 32 | ): 33 | """Intercept an exception and either re-raise, handle, or both. 34 | 35 | Example: 36 | 37 | .. code:: python 38 | 39 | from logging import getLogger 40 | from pydecor import intercept 41 | 42 | log = getLogger(__name__) 43 | 44 | class MyLibraryError(Exception): 45 | pass 46 | 47 | def error_handler(exception): 48 | log.exception(exception) 49 | 50 | # Re-raise and handle 51 | @intercept(catch=ValueError, reraise=MyLibraryError, 52 | handler=error_handler): 53 | def some_error_prone_function(): 54 | return int('foo') 55 | 56 | # Re-raise only 57 | @intercept(catch=TypeError, reraise=MyLibraryError) 58 | def another_error_prone_function(splittable_string): 59 | return splittable_string.split('/', '.') 60 | 61 | # Handle only 62 | @intercept(catch=ValueError, handle=error_handler) 63 | def ignorable_error(some_string): 64 | # Just run the handler on error, rather than re-raising 65 | log.info('This string is {}'.format(int(string))) 66 | 67 | 68 | :param Type[Exception] catch: the exception to catch 69 | :param Union[bool, Type[Exception]] reraise: the exception to 70 | re-raise or ``True``. If an exception is provided, that 71 | exception will be raised. If ``True``, the original 72 | exception will be re-raised. If ``False`` or ``None``, 73 | no exception will be raised. Note that if a ``handler`` 74 | is provided, it will always be called prior to re-raising. 75 | :param Callable[[Type[Exception]],Any] handler: a function to call 76 | with the caught exception as its only argument. If not provided, 77 | nothing will be done with the caught exception 78 | :param str err_msg: An optional string with which to call the 79 | re-raised exception. If not provided, the caught exception 80 | will be cast to a string and used instead. 81 | :param include_context: if True, the previous exception will be 82 | included in the context of the re-raised exception, which 83 | means the traceback will show both exceptions, noting that 84 | the first exception "was the direct cause of the following 85 | exception" 86 | """ 87 | return instead( 88 | functions.intercept, 89 | catch=catch, 90 | reraise=reraise, 91 | handler=handler, 92 | err_msg=err_msg, 93 | include_context=include_context, 94 | ) 95 | 96 | 97 | def log_call(logger=None, level="info", format_str=LOG_CALL_FMT_STR): 98 | """Log the name, parameters, & result of a function call 99 | 100 | If not provided, a logger instance will be retrieved corresponding 101 | to the module name of the decorated function, so if you decorate 102 | the function ``do_magic()`` in the module ``magic.py``, the 103 | retrieved logger will be the same as one retrieved in ``magic.py`` 104 | with ``logging.getLogger(__name__)`` 105 | 106 | The ``level`` provided here **does not** set the log level of the 107 | passed or retrieved logger. It just determines what level the 108 | generated message should be logged with. 109 | 110 | Example: 111 | 112 | * Assume the following is found in ``log_example.py`` 113 | 114 | .. code:: python 115 | 116 | from pydecor import log_call 117 | 118 | @log_call 119 | def return_none(*args, **kwargs): 120 | return None 121 | 122 | return_none('alright', my='man') 123 | 124 | # Will retrieve the ``log_example`` logger 125 | # and log the message: 126 | # "return_none(*('alright', ), **{'my': 'man'}) -> None" 127 | 128 | :param Optional[logging.Logger] logger: an optional Logger 129 | instance. If not provided, the logger corresponding to the 130 | decorated function's module name will be retrieved 131 | :param str level: the level with which to log the message. Must 132 | be an acceptable Python log level 133 | :param str format_str: the format string to use when interpolating 134 | the message. This defaults to 135 | ``'{name}(*{args}, **{kwargs}) -> {result}'``. Any provided 136 | format string should contain the same keys, which will be 137 | interpolated appropriately. 138 | 139 | :rtype: DecoratorType 140 | """ 141 | return after( 142 | functions.log_call, logger=logger, level=level, format_str=format_str, 143 | ) 144 | 145 | 146 | def memoize(keep=0, cache_class=LRUCache): 147 | """Memoize the decorated function 148 | 149 | The default cache is an infinitely growing LRU Cache. To specify 150 | a maximum number of key/value pairs to keep, specify ``keep``. 151 | To specify a different cache, pass it to ``cache_class``. Any 152 | class that implements ``__contains__``, ``__getitem__``, and 153 | ``__setitem__`` may be used. as the cache. There are several 154 | cache types provided in `pydecor.caches`_. 155 | 156 | :param int keep: the maximum size of the cache (or max age of the 157 | cache entries in seconds when using TimedCache). By default 158 | this is 0, which means the cache can grow indefinitely. 159 | :param cache_class: the cache to store function results. 160 | Any class that supports __getitem__, __setitem__, 161 | and __contains__ should work just fine here. The 162 | max_size is passed in as an instantiation parameter, 163 | so that should also be supported. In addition to 164 | the default LRUCache, a LIFOCache and a TimedCache 165 | are provided in ``pydecor.caches``. 166 | 167 | :rtype: DecoratorType 168 | """ 169 | return instead(functions.memoize, memo=cache_class(keep)) 170 | -------------------------------------------------------------------------------- /src/pydecor/functions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Functions to use for decorator construction 4 | """ 5 | 6 | from __future__ import absolute_import, unicode_literals 7 | 8 | 9 | __all__ = ("intercept", "log_call") 10 | 11 | 12 | from inspect import getmodule 13 | from logging import getLogger 14 | from six import raise_from 15 | from sys import version_info 16 | 17 | 18 | from .constants import LOG_CALL_FMT_STR 19 | from ._memoization import convert_to_hashable 20 | 21 | 22 | PY2 = version_info < (3, 0) 23 | 24 | 25 | def intercept( 26 | decorated, 27 | catch=Exception, 28 | reraise=None, 29 | handler=None, 30 | err_msg=None, 31 | include_context=False, 32 | ): 33 | """Intercept an error and either re-raise, handle, or both 34 | 35 | Designed to be called via the ``instead`` decorator. 36 | 37 | :param Decorated decorated: decorated function information 38 | :param Type[Exception] catch: an exception to intercept 39 | :param Union[bool, Type[Exception]] reraise: if provided, will re-raise 40 | the provided exception, after running any provided 41 | handler callable. If ``False`` or ``None``, no exception 42 | will be re-raised. 43 | :param Callable[[Type[Exception]],Any] handler: a function 44 | to call with the caught exception as its only argument. 45 | If not provided, nothing will be done with the caught 46 | exception. 47 | :param str err_msg: if included will be used to instantiate 48 | the exception. If not included, the caught exception will 49 | be cast to a string and used instead 50 | :param include_context: if True, the previous exception will 51 | be included in the exception context. 52 | """ 53 | try: 54 | return decorated(*decorated.args, **decorated.kwargs) 55 | 56 | except catch as exc: 57 | if handler is not None: 58 | handler(exc) 59 | 60 | if isinstance(reraise, bool): 61 | if reraise: 62 | raise 63 | 64 | elif reraise is not None: 65 | 66 | if err_msg is not None: 67 | new_exc = reraise(err_msg) 68 | else: 69 | new_exc = reraise(str(exc)) 70 | 71 | context = exc if include_context else None 72 | raise_from(new_exc, context) 73 | 74 | 75 | def log_call(decorated, logger=None, level="info", format_str=LOG_CALL_FMT_STR): 76 | """Log the parameters & results of a function call 77 | 78 | Designed to be called via the ``after`` decorator with 79 | ``pass_params=True`` and ``pass_decorated=True``. Use 80 | :any:`decorators.log_call` for easiest invocation. 81 | 82 | :param Decorated decorated: decorated function information 83 | :param Optional[logging.Logger] logger: optional logger instance 84 | :param Optional[str] level: log level - must be an acceptable Python 85 | log level, case-insensitive 86 | :param format_str: the string to use when formatting the results 87 | """ 88 | module = getmodule(decorated.wrapped) 89 | if logger is None: 90 | name = module.__name__ if module is not None else "__main__" 91 | logger = getLogger(name) 92 | log_fn = getattr(logger, level.lower()) 93 | msg = format_str.format( 94 | name=decorated.wrapped.__name__, 95 | args=decorated.args, 96 | kwargs=decorated.kwargs, 97 | result=decorated.result, 98 | ) 99 | log_fn(msg) 100 | 101 | 102 | def memoize(decorated, memo): 103 | """Return a memoized result if possible; store if not present 104 | 105 | :param Decorator decorated: decorated function information 106 | :param memo: the memoization cache. Must support standard 107 | __getitem__ and __setitem__ calls 108 | """ 109 | key = convert_to_hashable(decorated.args, decorated.kwargs) 110 | if key in memo: 111 | return memo[key] 112 | res = decorated(*decorated.args, **decorated.kwargs) 113 | memo[key] = res 114 | return res 115 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mplanchard/pydecor/d506ca881dc8ae0f1dcc84e442b3192b157e5d5e/tests/__init__.py -------------------------------------------------------------------------------- /tests/decorators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mplanchard/pydecor/d506ca881dc8ae0f1dcc84e442b3192b157e5d5e/tests/decorators/__init__.py -------------------------------------------------------------------------------- /tests/decorators/exports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mplanchard/pydecor/d506ca881dc8ae0f1dcc84e442b3192b157e5d5e/tests/decorators/exports/__init__.py -------------------------------------------------------------------------------- /tests/decorators/exports/class_export.py: -------------------------------------------------------------------------------- 1 | """Export a class.""" 2 | 3 | from pydecor.decorators import export 4 | 5 | 6 | @export 7 | class Exported: 8 | pass 9 | -------------------------------------------------------------------------------- /tests/decorators/exports/list_all.py: -------------------------------------------------------------------------------- 1 | """A module using export, for validation.""" 2 | 3 | __all__ = ["first"] 4 | 5 | from pydecor.decorators import export 6 | 7 | 8 | def first(): 9 | """Allow testing of __all__ appending.""" 10 | 11 | 12 | @export 13 | def exported(): 14 | """Allow testing of @export.""" 15 | -------------------------------------------------------------------------------- /tests/decorators/exports/multi_export.py: -------------------------------------------------------------------------------- 1 | """All varieties of acceptable exports.""" 2 | 3 | from pydecor.decorators import export 4 | 5 | 6 | @export 7 | class Exported: 8 | def foo(self): 9 | pass 10 | 11 | 12 | @export 13 | def exported(): 14 | pass 15 | -------------------------------------------------------------------------------- /tests/decorators/exports/no_all.py: -------------------------------------------------------------------------------- 1 | """A module using export, for validation.""" 2 | 3 | from pydecor.decorators import export 4 | 5 | 6 | @export 7 | def exported(): 8 | """Allow testing of @export.""" 9 | -------------------------------------------------------------------------------- /tests/decorators/exports/tuple_all.py: -------------------------------------------------------------------------------- 1 | """A module using export, for validation.""" 2 | 3 | __all__ = ("first",) 4 | 5 | from pydecor.decorators import export 6 | 7 | 8 | def first(): 9 | """Allow testing of __all__ appending.""" 10 | 11 | 12 | @export 13 | def exported(): 14 | """Allow testing of @export.""" 15 | -------------------------------------------------------------------------------- /tests/decorators/test_export.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """Tests for the @export decorator.""" 3 | 4 | import importlib 5 | import sys 6 | import textwrap 7 | import types 8 | from importlib.machinery import ModuleSpec 9 | from importlib.util import module_from_spec 10 | 11 | import pytest 12 | 13 | from pydecor.decorators import export 14 | 15 | 16 | @pytest.fixture() 17 | def reset_modules(): 18 | """Ensure sys.modules is reset at the end of a test.""" 19 | modules = dict(sys.modules) 20 | yield 21 | sys.modules = modules 22 | 23 | 24 | class TestExport: 25 | """Test the @export decorator.""" 26 | 27 | def test_bad_type(self): 28 | """Passing something with no __module__ attribute is a TypeError.""" 29 | with pytest.raises(TypeError): 30 | export("foo") # type: ignore 31 | 32 | def test_not_in_modules(self): 33 | """Calling in a non-imported context is an error.""" 34 | module = types.ModuleType("my_module") # creates a new module 35 | 36 | module_code = textwrap.dedent( 37 | """ 38 | from pydecor.decorators import export 39 | 40 | @export 41 | def exported(): 42 | pass 43 | """ 44 | ) 45 | with pytest.raises(ValueError): 46 | exec(module_code, module.__dict__) 47 | 48 | @pytest.mark.usefixtures("reset_modules") 49 | def test_imported_module_dynamic(self): 50 | """The __all__ attr is created on the imported module if needed.""" 51 | # In actual import machinery, the module is added to sys.modules 52 | # before the contained code is executed, so we mimic that here. 53 | module = module_from_spec(ModuleSpec("my_module", None)) 54 | sys.modules["my_module"] = module 55 | 56 | module_code = textwrap.dedent( 57 | """ 58 | from pydecor.decorators import export 59 | 60 | @export 61 | def exported(): 62 | pass 63 | """ 64 | ) 65 | exec(module_code, module.__dict__) 66 | 67 | imported = importlib.import_module("my_module") 68 | assert imported.__all__ == ["exported"] # type: ignore 69 | 70 | @pytest.mark.usefixtures("reset_modules") 71 | def test_imported_module_dynamic_append(self): 72 | """The __all__ attr is appended to if it already exists.""" 73 | # In actual import machinery, the module is added to sys.modules 74 | # before the contained code is executed, so we mimic that here. 75 | module = module_from_spec(ModuleSpec("my_module", None)) 76 | sys.modules["my_module"] = module 77 | 78 | module_code = textwrap.dedent( 79 | """ 80 | __all__ = ["first"] 81 | 82 | from pydecor.decorators import export 83 | 84 | first = "some other thing that is already exported" 85 | 86 | @export 87 | def exported(): 88 | pass 89 | """ 90 | ) 91 | exec(module_code, module.__dict__) 92 | 93 | imported = importlib.import_module("my_module") 94 | assert imported.__all__ == ["first", "exported"] # type: ignore 95 | 96 | @pytest.mark.usefixtures("reset_modules") 97 | def test_imported_module_dynamic_append_tuple(self): 98 | """If __all__ is a tuple, the generated one is still a tuple.""" 99 | # In actual import machinery, the module is added to sys.modules 100 | # before the contained code is executed, so we mimic that here. 101 | module = module_from_spec(ModuleSpec("my_module", None)) 102 | sys.modules["my_module"] = module 103 | 104 | module_code = textwrap.dedent( 105 | """ 106 | __all__ = ("first",) 107 | 108 | from pydecor.decorators import export 109 | 110 | first = "some other thing that is already exported" 111 | 112 | @export 113 | def exported(): 114 | pass 115 | """ 116 | ) 117 | exec(module_code, module.__dict__) 118 | 119 | imported = importlib.import_module("my_module") 120 | assert imported.__all__ == ("first", "exported") # type: ignore 121 | 122 | @pytest.mark.usefixtures("reset_modules") 123 | def test_export_idempotent_already_present(self): 124 | """The module is not added if already present.""" 125 | # In actual import machinery, the module is added to sys.modules 126 | # before the contained code is executed, so we mimic that here. 127 | module = module_from_spec(ModuleSpec("my_module", None)) 128 | sys.modules["my_module"] = module 129 | 130 | module_code = textwrap.dedent( 131 | """ 132 | __all__ = ["exported"] 133 | 134 | from pydecor.decorators import export 135 | 136 | @export 137 | def exported(): 138 | pass 139 | """ 140 | ) 141 | exec(module_code, module.__dict__) 142 | 143 | imported = importlib.import_module("my_module") 144 | assert imported.__all__ == ["exported"] # type: ignore 145 | 146 | @pytest.mark.usefixtures("reset_modules") 147 | def test_export_idempotent_multiple_calls(self): 148 | """Multiple calls don't hurt.""" 149 | # In actual import machinery, the module is added to sys.modules 150 | # before the contained code is executed, so we mimic that here. 151 | module = module_from_spec(ModuleSpec("my_module", None)) 152 | sys.modules["my_module"] = module 153 | 154 | module_code = textwrap.dedent( 155 | """ 156 | from pydecor.decorators import export 157 | 158 | @export 159 | @export 160 | def exported(): 161 | pass 162 | """ 163 | ) 164 | exec(module_code, module.__dict__) 165 | 166 | imported = importlib.import_module("my_module") 167 | assert imported.__all__ == ["exported"] # type: ignore 168 | 169 | @pytest.mark.usefixtures("reset_modules") 170 | def test_imported_module_static_no_all(self): 171 | """A module present in imports is manipulated correctly.""" 172 | from .exports import no_all 173 | 174 | # pylint: disable=no-member 175 | assert no_all.__all__ == ["exported"] # type: ignore 176 | # pylint: enable=no-member 177 | 178 | @pytest.mark.usefixtures("reset_modules") 179 | def test_imported_module_static_list_all(self): 180 | """A list __all__ is appended to.""" 181 | from .exports import list_all 182 | 183 | assert list_all.__all__ == ["first", "exported"] 184 | 185 | @pytest.mark.usefixtures("reset_modules") 186 | def test_imported_module_static_tuple_all(self): 187 | """A tuple __all__ is replaced with a new tuple.""" 188 | from .exports import tuple_all 189 | 190 | assert tuple_all.__all__ == ("first", "exported") 191 | 192 | @pytest.mark.usefixtures("reset_modules") 193 | def test_imported_module_static_class_export(self): 194 | """Classes may also be exported.""" 195 | from .exports import class_export 196 | 197 | # pylint: disable=no-member 198 | assert class_export.__all__ == ["Exported"] # type: ignore 199 | 200 | @pytest.mark.usefixtures("reset_modules") 201 | def test_imported_module_static_multi_export(self): 202 | """Multiple items may be exported.""" 203 | from .exports import multi_export 204 | 205 | # pylint: disable=no-member 206 | assert multi_export.__all__ == ["Exported", "exported"] # type: ignore 207 | 208 | @pytest.mark.usefixtures("reset_modules") 209 | def test_export_class_dynamic(self): 210 | """Classes may also be exported.""" 211 | # In actual import machinery, the module is added to sys.modules 212 | # before the contained code is executed, so we mimic that here. 213 | module = module_from_spec(ModuleSpec("my_module", None)) 214 | sys.modules["my_module"] = module 215 | 216 | module_code = textwrap.dedent( 217 | """ 218 | from pydecor.decorators import export 219 | 220 | @export 221 | class Exported: 222 | pass 223 | 224 | """ 225 | ) 226 | exec(module_code, module.__dict__) 227 | 228 | imported = importlib.import_module("my_module") 229 | assert imported.__all__ == ["Exported"] # type: ignore 230 | 231 | @pytest.mark.usefixtures("reset_modules") 232 | def test_export_instancemethod_fails(self): 233 | """Instance methods may not be exported directly.""" 234 | # In actual import machinery, the module is added to sys.modules 235 | # before the contained code is executed, so we mimic that here. 236 | module = module_from_spec(ModuleSpec("my_module", None)) 237 | sys.modules["my_module"] = module 238 | 239 | module_code = textwrap.dedent( 240 | """ 241 | from pydecor.decorators import export 242 | 243 | class BadClass: 244 | @export 245 | def foo(self): 246 | pass 247 | 248 | """ 249 | ) 250 | with pytest.raises(TypeError): 251 | exec(module_code, module.__dict__) 252 | 253 | @pytest.mark.usefixtures("reset_modules") 254 | def test_export_classmethod_fails(self): 255 | """Classmethods may not be exported directly.""" 256 | # In actual import machinery, the module is added to sys.modules 257 | # before the contained code is executed, so we mimic that here. 258 | module = module_from_spec(ModuleSpec("my_module", None)) 259 | sys.modules["my_module"] = module 260 | 261 | module_code = textwrap.dedent( 262 | """ 263 | from pydecor.decorators import export 264 | 265 | class BadClass: 266 | @export 267 | @classmethod 268 | def foo(self): 269 | pass 270 | 271 | """ 272 | ) 273 | with pytest.raises(TypeError): 274 | exec(module_code, module.__dict__) 275 | 276 | @pytest.mark.usefixtures("reset_modules") 277 | def test_export_staticmethod_fails(self): 278 | """Staticmethods may not be exported directly.""" 279 | # In actual import machinery, the module is added to sys.modules 280 | # before the contained code is executed, so we mimic that here. 281 | module = module_from_spec(ModuleSpec("my_module", None)) 282 | sys.modules["my_module"] = module 283 | 284 | module_code = textwrap.dedent( 285 | """ 286 | from pydecor.decorators import export 287 | 288 | class BadClass: 289 | @export 290 | @staticmethod 291 | def foo(self): 292 | pass 293 | 294 | """ 295 | ) 296 | with pytest.raises(TypeError): 297 | exec(module_code, module.__dict__) 298 | 299 | @pytest.mark.usefixtures("reset_modules") 300 | @pytest.mark.parametrize( 301 | "value", 302 | ( 303 | "lambda: None", 304 | "'foo'", 305 | "12", 306 | "{}", 307 | "[]", 308 | "()", 309 | "set()", 310 | "None", 311 | "False", 312 | "range(5)", 313 | "iter(())", 314 | ), 315 | ) 316 | def test_export_failure_inline_expression(self, value): 317 | """Inline expressions are not valid input.""" 318 | # In actual import machinery, the module is added to sys.modules 319 | # before the contained code is executed, so we mimic that here. 320 | module = module_from_spec(ModuleSpec("my_module", None)) 321 | sys.modules["my_module"] = module 322 | 323 | module_code = textwrap.dedent( 324 | """ 325 | from pydecor.decorators import export 326 | export(lambda: None) 327 | """ 328 | ) 329 | with pytest.raises(TypeError): 330 | exec(module_code, module.__dict__) 331 | 332 | @pytest.mark.usefixtures("reset_modules") 333 | def test_export_failure_local_function(self): 334 | """Interior functions may not be exported.""" 335 | # In actual import machinery, the module is added to sys.modules 336 | # before the contained code is executed, so we mimic that here. 337 | module = module_from_spec(ModuleSpec("my_module", None)) 338 | sys.modules["my_module"] = module 339 | 340 | module_code = textwrap.dedent( 341 | """ 342 | from pydecor.decorators import export 343 | 344 | def foo(): 345 | @export 346 | def inner(): 347 | pass 348 | 349 | foo() 350 | """ 351 | ) 352 | with pytest.raises(TypeError): 353 | exec(module_code, module.__dict__) 354 | 355 | @pytest.mark.usefixtures("reset_modules") 356 | def test_export_failure_class_instance(self): 357 | """Class instances may not be exported.""" 358 | # In actual import machinery, the module is added to sys.modules 359 | # before the contained code is executed, so we mimic that here. 360 | module = module_from_spec(ModuleSpec("my_module", None)) 361 | sys.modules["my_module"] = module 362 | 363 | module_code = textwrap.dedent( 364 | """ 365 | from pydecor.decorators import export 366 | 367 | class Foo: pass 368 | 369 | foo = Foo() 370 | 371 | export(foo) 372 | """ 373 | ) 374 | with pytest.raises(TypeError): 375 | exec(module_code, module.__dict__) 376 | -------------------------------------------------------------------------------- /tests/decorators/test_generics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Tests for the decorators module 4 | """ 5 | 6 | from __future__ import absolute_import, unicode_literals 7 | 8 | 9 | import typing as t 10 | from unittest.mock import Mock 11 | 12 | import pytest 13 | 14 | from pydecor.decorators import ( 15 | after, 16 | before, 17 | construct_decorator, 18 | decorate, 19 | Decorated, 20 | instead, 21 | ) 22 | 23 | 24 | class TestDecorated: 25 | """Test the Decorated wrapper.""" 26 | 27 | def test_str(self): 28 | """The __name__ is included in the string.""" 29 | assert "TestDecorated" in str(Decorated(self.__class__, (), {})) 30 | 31 | def test_call(self): 32 | """Calling gets the original result.""" 33 | assert Decorated(lambda: 1, (), {})() == 1 34 | 35 | def test_call_sets_result(self): 36 | """Calling gets the original result.""" 37 | decorated = Decorated(lambda: 1, (), {}) 38 | assert decorated() == 1 39 | assert decorated.result == 1 40 | 41 | def test_immutable(self): 42 | """Decorated objects are immutable.""" 43 | with pytest.raises(AttributeError): 44 | Decorated(lambda: None, (), {}).result = "bar" 45 | 46 | 47 | class TestBefore: 48 | """Test generic decorators.""" 49 | 50 | def test_before_no_ret(self): 51 | """A before decorator with no return does not replace inbound args.""" 52 | 53 | tracker: t.List[dict] = [] 54 | 55 | def to_call_before(decorated: Decorated) -> None: 56 | # Ensure this happens before the wrapped call. 57 | tracker.append({1: decorated.args}) 58 | 59 | @before(to_call_before) 60 | def to_call(*args): 61 | tracker.append({2: args}) 62 | 63 | to_call(1, 2) 64 | 65 | assert len(tracker) == 2 66 | assert tracker[0] == {1: (1, 2)} 67 | assert tracker[1] == {2: (1, 2)} 68 | 69 | def test_before_ret(self): 70 | """A before decorator's return, if present, replaces inbound args.""" 71 | 72 | tracker: t.List[dict] = [] 73 | 74 | def to_call_before(decorated: Decorated) -> t.Tuple[tuple, dict]: 75 | # Ensure this happens before the wrapped call. 76 | tracker.append({1: decorated.args}) 77 | return (3, 4), {} 78 | 79 | @before(to_call_before) 80 | def to_call(*args): 81 | tracker.append({2: args}) 82 | 83 | to_call(1, 2) 84 | 85 | assert len(tracker) == 2 86 | assert tracker[0] == {1: (1, 2)} 87 | assert tracker[1] == {2: (3, 4)} 88 | 89 | def test_before_receives_kwargs(self): 90 | """Any kwargs are passed to the callable.""" 91 | 92 | tracker: t.List[dict] = [] 93 | 94 | def to_call_before(decorated: Decorated, extra=None) -> None: 95 | # Ensure this happens before the wrapped call. 96 | tracker.append({1: (decorated.args, extra)}) 97 | 98 | @before(to_call_before, extra="read_all_about_it") 99 | def to_call(*args): 100 | tracker.append({2: args}) 101 | 102 | to_call(1, 2) 103 | 104 | assert len(tracker) == 2 105 | assert tracker[0] == {1: ((1, 2), "read_all_about_it")} 106 | assert tracker[1] == {2: (1, 2)} 107 | 108 | def test_before_implicit_instancemethod(self): 109 | """Before implicitly decorates instancemethods.""" 110 | 111 | tracker: t.List[dict] = [] 112 | 113 | def to_call_before(decorated: Decorated) -> None: 114 | # Ensure this happens before the wrapped call. 115 | tracker.append({1: decorated.args}) 116 | 117 | @before(to_call_before) 118 | class _ToDecorate: 119 | def to_call(self, *args): 120 | tracker.append({2: args}) 121 | 122 | _ToDecorate().to_call(1, 2) 123 | 124 | assert len(tracker) == 2 125 | assert tracker[0] == {1: (1, 2)} 126 | assert tracker[1] == {2: (1, 2)} 127 | 128 | def test_before_implicit_classmethod(self): 129 | """Before implicitly decorates classmethods.""" 130 | 131 | tracker: t.List[dict] = [] 132 | 133 | def to_call_before(decorated: Decorated) -> None: 134 | # Ensure this happens before the wrapped call. 135 | tracker.append({1: decorated.args}) 136 | 137 | @before(to_call_before) 138 | class _ToDecorate: 139 | @classmethod 140 | def to_call(cls, *args): 141 | tracker.append({2: args}) 142 | 143 | _ToDecorate().to_call(1, 2) 144 | 145 | assert len(tracker) == 2 146 | assert tracker[0] == {1: (1, 2)} 147 | assert tracker[1] == {2: (1, 2)} 148 | 149 | def test_before_implicit_staticmethod(self): 150 | """Before implicitly decorates staticmethods.""" 151 | 152 | tracker: t.List[dict] = [] 153 | 154 | def to_call_before(decorated: Decorated) -> None: 155 | # Ensure this happens before the wrapped call. 156 | tracker.append({1: decorated.args}) 157 | 158 | @before(to_call_before) 159 | class _ToDecorate: 160 | @staticmethod 161 | def to_call(*args): 162 | tracker.append({2: args}) 163 | 164 | _ToDecorate().to_call(1, 2) 165 | 166 | assert len(tracker) == 2 167 | assert tracker[0] == {1: (1, 2)} 168 | assert tracker[1] == {2: (1, 2)} 169 | 170 | def test_before_implicit_instancemethod_instace_only(self): 171 | """Instance methods can be decorated in isolation.""" 172 | 173 | tracker: t.List[dict] = [] 174 | 175 | def to_call_before(decorated: Decorated) -> None: 176 | # Ensure this happens before the wrapped call. 177 | tracker.append({1: decorated.args}) 178 | 179 | @before(to_call_before, instance_methods_only=True) 180 | class _ToDecorate: 181 | def to_call(self, *args): 182 | tracker.append({2: args}) 183 | 184 | _ToDecorate().to_call(1, 2) 185 | 186 | assert len(tracker) == 2 187 | assert tracker[0] == {1: (1, 2)} 188 | assert tracker[1] == {2: (1, 2)} 189 | 190 | def test_before_implicit_classmethod_instance_only(self): 191 | """Instance methods can be decorated in isolation.""" 192 | 193 | tracker: t.List[dict] = [] 194 | 195 | def to_call_before(decorated: Decorated) -> None: 196 | # Ensure this happens before the wrapped call. 197 | tracker.append({1: decorated.args}) 198 | 199 | @before(to_call_before, instance_methods_only=True) 200 | class _ToDecorate: 201 | @classmethod 202 | def to_call(cls, *args): 203 | tracker.append({2: args}) 204 | 205 | _ToDecorate().to_call(1, 2) 206 | 207 | assert len(tracker) == 1 208 | assert tracker[0] == {2: (1, 2)} 209 | 210 | def test_before_implicit_staticmethod_instance_only(self): 211 | """Instance methods can be decorated in isolation.""" 212 | 213 | tracker: t.List[dict] = [] 214 | 215 | def to_call_before(decorated: Decorated) -> None: 216 | # Ensure this happens before the wrapped call. 217 | tracker.append({1: decorated.args}) 218 | 219 | @before(to_call_before, instance_methods_only=True) 220 | class _ToDecorate: 221 | @staticmethod 222 | def to_call(*args): 223 | tracker.append({2: args}) 224 | 225 | _ToDecorate().to_call(1, 2) 226 | 227 | assert len(tracker) == 1 228 | assert tracker[0] == {2: (1, 2)} 229 | 230 | def test_before_method_decorates_class_if_not_implicit(self): 231 | """Without implicit method decoration, the class init is decorated.""" 232 | 233 | tracker: t.List[dict] = [] 234 | 235 | def to_call_before(decorated: Decorated) -> None: 236 | # Ensure this happens before the wrapped call. 237 | tracker.append({1: decorated.args}) 238 | 239 | @before(to_call_before, implicit_method_decoration=False) 240 | class _ToDecorate: 241 | def __init__(self): 242 | tracker.append({0: ()}) 243 | 244 | def to_call(self, *args): 245 | tracker.append({2: args}) 246 | 247 | @classmethod 248 | def to_call_cls(cls, *args): 249 | tracker.append({3: args}) 250 | 251 | @staticmethod 252 | def to_call_static(*args): 253 | tracker.append({4: args}) 254 | 255 | to_decorate = _ToDecorate() 256 | 257 | to_decorate.to_call(3, 4) 258 | to_decorate.to_call_cls(3, 4) 259 | to_decorate.to_call_static(3, 4) 260 | 261 | assert len(tracker) == 5 262 | assert tracker[0] == {1: ()} 263 | assert tracker[1] == {0: ()} 264 | assert tracker[2] == {2: (3, 4)} 265 | assert tracker[3] == {3: (3, 4)} 266 | assert tracker[4] == {4: (3, 4)} 267 | 268 | def test_before_decorates_on_class_references(self): 269 | """Decorating class and staticmethods applies to the class ref.""" 270 | 271 | tracker: t.List[dict] = [] 272 | 273 | def to_call_before(decorated: Decorated) -> None: 274 | # Ensure this happens before the wrapped call. 275 | tracker.append({1: decorated.args}) 276 | 277 | @before(to_call_before) 278 | class _ToDecorate: 279 | @classmethod 280 | def to_call_cls(cls, *args): 281 | tracker.append({2: args}) 282 | 283 | @staticmethod 284 | def to_call_static(*args): 285 | tracker.append({3: args}) 286 | 287 | _ToDecorate.to_call_cls(1, 2) 288 | _ToDecorate.to_call_static(3, 4) 289 | 290 | assert len(tracker) == 4 291 | assert tracker[0] == {1: (1, 2)} 292 | assert tracker[1] == {2: (1, 2)} 293 | assert tracker[2] == {1: (3, 4)} 294 | assert tracker[3] == {3: (3, 4)} 295 | 296 | def test_before_direct_method_decoration_equivalent(self): 297 | """Direct and implicit decoration work the same way.""" 298 | 299 | tracker: t.List[dict] = [] 300 | 301 | def to_call_before(decorated: Decorated) -> None: 302 | # Ensure this happens before the wrapped call. 303 | tracker.append({1: decorated.args}) 304 | 305 | class _ToDecorate: 306 | @before(to_call_before) 307 | def to_call(self, *args): 308 | tracker.append({2: args}) 309 | 310 | @classmethod 311 | @before(to_call_before) 312 | def to_call_cls(cls, *args): 313 | tracker.append({3: args}) 314 | 315 | @staticmethod 316 | @before(to_call_before) 317 | def to_call_static(*args): 318 | tracker.append({4: args}) 319 | 320 | instance = _ToDecorate() 321 | instance.to_call(1, 2) 322 | _ToDecorate().to_call_cls(3, 4) 323 | _ToDecorate().to_call_static(5, 6) 324 | 325 | assert len(tracker) == 6 326 | assert tracker[0] == {1: (instance, 1, 2)} 327 | assert tracker[1] == {2: (1, 2)} 328 | assert tracker[2] == {1: (3, 4)} 329 | assert tracker[3] == {3: (3, 4)} 330 | assert tracker[4] == {1: (5, 6)} 331 | assert tracker[5] == {4: (5, 6)} 332 | 333 | 334 | class TestAfter: 335 | """Test generic decorators.""" 336 | 337 | def test_after_no_ret(self): 338 | """A after decorator with no return does not affect teh return value.""" 339 | 340 | tracker: t.List[dict] = [] 341 | 342 | def to_call_after(decorated: Decorated) -> None: 343 | # Ensure this happens after the wrapped call. 344 | tracker.append({1: decorated.result}) 345 | 346 | @after(to_call_after) 347 | def to_call(*args): 348 | tracker.append({2: args}) 349 | return 0 350 | 351 | assert to_call(1, 2) == 0 352 | 353 | assert len(tracker) == 2 354 | assert tracker[0] == {2: (1, 2)} 355 | assert tracker[1] == {1: 0} 356 | 357 | def test_after_ret(self): 358 | """A after decorator's return, if present, replaces fn return.""" 359 | 360 | tracker: t.List[dict] = [] 361 | 362 | def to_call_after(decorated: Decorated) -> int: 363 | # Ensure this happens after the wrapped call. 364 | tracker.append({1: decorated.result}) 365 | return 1 366 | 367 | @after(to_call_after) 368 | def to_call(*args): 369 | tracker.append({2: args}) 370 | return 0 371 | 372 | assert to_call(1, 2) == 1 373 | 374 | assert len(tracker) == 2 375 | assert tracker[0] == {2: (1, 2)} 376 | assert tracker[1] == {1: 0} 377 | 378 | def test_after_receives_kwargs(self): 379 | """Any kwargs are passed to the callable.""" 380 | 381 | tracker: t.List[dict] = [] 382 | 383 | def to_call_after(decorated: Decorated, extra=None) -> None: 384 | # Ensure this happens after the wrapped call. 385 | tracker.append({1: (decorated.args, extra)}) 386 | 387 | @after(to_call_after, extra="read_all_about_it") 388 | def to_call(*args): 389 | tracker.append({2: args}) 390 | 391 | to_call(1, 2) 392 | 393 | assert len(tracker) == 2 394 | assert tracker[0] == {2: (1, 2)} 395 | assert tracker[1] == {1: ((1, 2), "read_all_about_it")} 396 | 397 | def test_after_implicit_instancemethod(self): 398 | """Before implicitly decorates instancemethods.""" 399 | 400 | tracker: t.List[dict] = [] 401 | 402 | def to_call_after(decorated: Decorated) -> None: 403 | # Ensure this happens after the wrapped call. 404 | tracker.append({1: decorated.args}) 405 | 406 | @after(to_call_after) 407 | class _ToDecorate: 408 | def to_call(self, *args): 409 | tracker.append({2: args}) 410 | 411 | _ToDecorate().to_call(1, 2) 412 | 413 | assert len(tracker) == 2 414 | assert tracker[0] == {2: (1, 2)} 415 | assert tracker[1] == {1: (1, 2)} 416 | 417 | def test_after_implicit_classmethod(self): 418 | """Before implicitly decorates classmethods.""" 419 | 420 | tracker: t.List[dict] = [] 421 | 422 | def to_call_after(decorated: Decorated) -> None: 423 | # Ensure this happens after the wrapped call. 424 | tracker.append({1: decorated.args}) 425 | 426 | @after(to_call_after) 427 | class _ToDecorate: 428 | @classmethod 429 | def to_call(cls, *args): 430 | tracker.append({2: args}) 431 | 432 | _ToDecorate().to_call(1, 2) 433 | 434 | assert len(tracker) == 2 435 | assert tracker[0] == {2: (1, 2)} 436 | assert tracker[1] == {1: (1, 2)} 437 | 438 | def test_after_implicit_staticmethod(self): 439 | """Before implicitly decorates staticmethods.""" 440 | 441 | tracker: t.List[dict] = [] 442 | 443 | def to_call_after(decorated: Decorated) -> None: 444 | # Ensure this happens after the wrapped call. 445 | tracker.append({1: decorated.args}) 446 | 447 | @after(to_call_after) 448 | class _ToDecorate: 449 | @staticmethod 450 | def to_call(*args): 451 | tracker.append({2: args}) 452 | 453 | _ToDecorate().to_call(1, 2) 454 | 455 | assert len(tracker) == 2 456 | assert tracker[0] == {2: (1, 2)} 457 | assert tracker[1] == {1: (1, 2)} 458 | 459 | def test_after_implicit_instancemethod_instace_only(self): 460 | """Instance methods can be decorated in isolation.""" 461 | 462 | tracker: t.List[dict] = [] 463 | 464 | def to_call_after(decorated: Decorated) -> None: 465 | # Ensure this happens after the wrapped call. 466 | tracker.append({1: decorated.args}) 467 | 468 | @after(to_call_after, instance_methods_only=True) 469 | class _ToDecorate: 470 | def to_call(self, *args): 471 | tracker.append({2: args}) 472 | 473 | _ToDecorate().to_call(1, 2) 474 | 475 | assert len(tracker) == 2 476 | assert tracker[0] == {2: (1, 2)} 477 | assert tracker[1] == {1: (1, 2)} 478 | 479 | def test_after_implicit_classmethod_instance_only(self): 480 | """Instance methods can be decorated in isolation.""" 481 | 482 | tracker: t.List[dict] = [] 483 | 484 | def to_call_after(decorated: Decorated) -> None: 485 | # Ensure this happens after the wrapped call. 486 | tracker.append({1: decorated.args}) 487 | 488 | @after(to_call_after, instance_methods_only=True) 489 | class _ToDecorate: 490 | @classmethod 491 | def to_call(cls, *args): 492 | tracker.append({2: args}) 493 | 494 | _ToDecorate().to_call(1, 2) 495 | 496 | assert len(tracker) == 1 497 | assert tracker[0] == {2: (1, 2)} 498 | 499 | def test_after_implicit_staticmethod_instance_only(self): 500 | """Instance methods can be decorated in isolation.""" 501 | 502 | tracker: t.List[dict] = [] 503 | 504 | def to_call_after(decorated: Decorated) -> None: 505 | # Ensure this happens after the wrapped call. 506 | tracker.append({1: decorated.args}) 507 | 508 | @after(to_call_after, instance_methods_only=True) 509 | class _ToDecorate: 510 | @staticmethod 511 | def to_call(*args): 512 | tracker.append({2: args}) 513 | 514 | _ToDecorate().to_call(1, 2) 515 | 516 | assert len(tracker) == 1 517 | assert tracker[0] == {2: (1, 2)} 518 | 519 | def test_after_method_decorates_class_if_not_implicit(self): 520 | """Without implicit method decoration, the class init is decorated.""" 521 | 522 | tracker: t.List[dict] = [] 523 | 524 | def to_call_after(decorated: Decorated) -> None: 525 | # Ensure this happens after the wrapped call. 526 | tracker.append({1: decorated.args}) 527 | 528 | @after(to_call_after, implicit_method_decoration=False) 529 | class _ToDecorate: 530 | def __init__(self): 531 | super().__init__() 532 | tracker.append({0: ()}) 533 | 534 | def to_call(self, *args): 535 | tracker.append({2: args}) 536 | 537 | @classmethod 538 | def to_call_cls(cls, *args): 539 | tracker.append({3: args}) 540 | 541 | @staticmethod 542 | def to_call_static(*args): 543 | tracker.append({4: args}) 544 | 545 | to_decorate = _ToDecorate() 546 | 547 | to_decorate.to_call(3, 4) 548 | to_decorate.to_call_cls(3, 4) 549 | to_decorate.to_call_static(3, 4) 550 | 551 | assert len(tracker) == 5 552 | assert tracker[0] == {0: ()} 553 | assert tracker[1] == {1: ()} 554 | assert tracker[2] == {2: (3, 4)} 555 | assert tracker[3] == {3: (3, 4)} 556 | assert tracker[4] == {4: (3, 4)} 557 | 558 | def test_after_decorates_on_class_references(self): 559 | """Decorating class and staticmethods applies to the class ref.""" 560 | 561 | tracker: t.List[dict] = [] 562 | 563 | def to_call_after(decorated: Decorated) -> None: 564 | # Ensure this happens after the wrapped call. 565 | tracker.append({1: decorated.args}) 566 | 567 | @after(to_call_after) 568 | class _ToDecorate: 569 | @classmethod 570 | def to_call_cls(cls, *args): 571 | tracker.append({2: args}) 572 | 573 | @staticmethod 574 | def to_call_static(*args): 575 | tracker.append({3: args}) 576 | 577 | _ToDecorate.to_call_cls(1, 2) 578 | _ToDecorate.to_call_static(3, 4) 579 | 580 | assert len(tracker) == 4 581 | assert tracker[0] == {2: (1, 2)} 582 | assert tracker[1] == {1: (1, 2)} 583 | assert tracker[2] == {3: (3, 4)} 584 | assert tracker[3] == {1: (3, 4)} 585 | 586 | def test_after_direct_method_decoration_equivalent(self): 587 | """Direct and implicit decoration work the same way.""" 588 | 589 | tracker: t.List[dict] = [] 590 | 591 | def to_call_after(decorated: Decorated) -> None: 592 | # Ensure this happens after the wrapped call. 593 | tracker.append({1: decorated.args}) 594 | 595 | class _ToDecorate: 596 | @after(to_call_after) 597 | def to_call(self, *args): 598 | tracker.append({2: args}) 599 | 600 | @classmethod 601 | @after(to_call_after) 602 | def to_call_cls(cls, *args): 603 | tracker.append({3: args}) 604 | 605 | @staticmethod 606 | @after(to_call_after) 607 | def to_call_static(*args): 608 | tracker.append({4: args}) 609 | 610 | instance = _ToDecorate() 611 | instance.to_call(1, 2) 612 | _ToDecorate().to_call_cls(3, 4) 613 | _ToDecorate().to_call_static(5, 6) 614 | 615 | assert len(tracker) == 6 616 | assert tracker[0] == {2: (1, 2)} 617 | assert tracker[1] == {1: (instance, 1, 2)} 618 | assert tracker[2] == {3: (3, 4)} 619 | assert tracker[3] == {1: (3, 4)} 620 | assert tracker[4] == {4: (5, 6)} 621 | assert tracker[5] == {1: (5, 6)} 622 | 623 | 624 | class TestInstead: 625 | """Test generic decorators.""" 626 | 627 | def test_instead_no_call(self): 628 | """A instead decorator is called in place of the decorated fn.""" 629 | 630 | tracker: t.List[dict] = [] 631 | 632 | def to_call_instead(decorated: Decorated) -> int: 633 | # Ensure this happens instead the wrapped call. 634 | tracker.append({1: decorated.args}) 635 | return 1 636 | 637 | @instead(to_call_instead) 638 | def to_call(*args): 639 | tracker.append({2: args}) 640 | return 0 641 | 642 | assert to_call(1, 2) == 1 643 | 644 | assert len(tracker) == 1 645 | assert tracker[0] == {1: (1, 2)} 646 | 647 | def test_instead_calls(self): 648 | """The decorated function must be called manually.""" 649 | 650 | tracker: t.List[dict] = [] 651 | 652 | def to_call_instead(decorated: Decorated) -> int: 653 | # Ensure this happens instead the wrapped call. 654 | decorated(*decorated.args, **decorated.kwargs) 655 | tracker.append({1: decorated.result}) 656 | return 1 657 | 658 | @instead(to_call_instead) 659 | def to_call(*args): 660 | tracker.append({2: args}) 661 | return 0 662 | 663 | assert to_call(1, 2) == 1 664 | 665 | assert len(tracker) == 2 666 | assert tracker[0] == {2: (1, 2)} 667 | assert tracker[1] == {1: 0} 668 | 669 | def test_instead_receives_kwargs(self): 670 | """Any kwargs are passed to the callable.""" 671 | 672 | tracker: t.List[dict] = [] 673 | 674 | def to_call_instead(decorated: Decorated, extra=None) -> None: 675 | # Ensure this happens instead the wrapped call. 676 | tracker.append({1: (decorated.args, extra)}) 677 | 678 | @instead(to_call_instead, extra="read_all_about_it") 679 | def to_call(*args): 680 | tracker.append({2: args}) 681 | 682 | to_call(1, 2) 683 | 684 | assert len(tracker) == 1 685 | assert tracker[0] == {1: ((1, 2), "read_all_about_it")} 686 | 687 | def test_instead_implicit_instancemethod(self): 688 | """Before implicitly decorates instancemethods.""" 689 | 690 | tracker: t.List[dict] = [] 691 | 692 | def to_call_instead(decorated: Decorated) -> None: 693 | # Ensure this happens instead the wrapped call. 694 | tracker.append({1: decorated.args}) 695 | 696 | @instead(to_call_instead) 697 | class _ToDecorate: 698 | def to_call(self, *args): 699 | tracker.append({2: args}) 700 | 701 | _ToDecorate().to_call(1, 2) 702 | 703 | assert len(tracker) == 1 704 | assert tracker[0] == {1: (1, 2)} 705 | 706 | def test_instead_implicit_classmethod(self): 707 | """Before implicitly decorates classmethods.""" 708 | 709 | tracker: t.List[dict] = [] 710 | 711 | def to_call_instead(decorated: Decorated) -> None: 712 | # Ensure this happens instead the wrapped call. 713 | tracker.append({1: decorated.args}) 714 | 715 | @instead(to_call_instead) 716 | class _ToDecorate: 717 | @classmethod 718 | def to_call(cls, *args): 719 | tracker.append({2: args}) 720 | 721 | _ToDecorate().to_call(1, 2) 722 | 723 | assert len(tracker) == 1 724 | assert tracker[0] == {1: (1, 2)} 725 | 726 | def test_instead_implicit_staticmethod(self): 727 | """Before implicitly decorates staticmethods.""" 728 | 729 | tracker: t.List[dict] = [] 730 | 731 | def to_call_instead(decorated: Decorated) -> None: 732 | # Ensure this happens instead the wrapped call. 733 | tracker.append({1: decorated.args}) 734 | 735 | @instead(to_call_instead) 736 | class _ToDecorate: 737 | @staticmethod 738 | def to_call(*args): 739 | tracker.append({2: args}) 740 | 741 | _ToDecorate().to_call(1, 2) 742 | 743 | assert len(tracker) == 1 744 | assert tracker[0] == {1: (1, 2)} 745 | 746 | def test_instead_implicit_instancemethod_instace_only(self): 747 | """Instance methods can be decorated in isolation.""" 748 | 749 | tracker: t.List[dict] = [] 750 | 751 | def to_call_instead(decorated: Decorated) -> None: 752 | # Ensure this happens instead the wrapped call. 753 | tracker.append({1: decorated.args}) 754 | 755 | @instead(to_call_instead, instance_methods_only=True) 756 | class _ToDecorate: 757 | def to_call(self, *args): 758 | tracker.append({2: args}) 759 | 760 | _ToDecorate().to_call(1, 2) 761 | 762 | assert len(tracker) == 1 763 | assert tracker[0] == {1: (1, 2)} 764 | 765 | def test_instead_implicit_classmethod_instance_only(self): 766 | """Instance methods can be decorated in isolation.""" 767 | 768 | tracker: t.List[dict] = [] 769 | 770 | def to_call_instead(decorated: Decorated) -> None: 771 | # Ensure this happens instead the wrapped call. 772 | tracker.append({1: decorated.args}) 773 | 774 | @instead(to_call_instead, instance_methods_only=True) 775 | class _ToDecorate: 776 | @classmethod 777 | def to_call(cls, *args): 778 | tracker.append({2: args}) 779 | 780 | _ToDecorate().to_call(1, 2) 781 | 782 | assert len(tracker) == 1 783 | assert tracker[0] == {2: (1, 2)} 784 | 785 | def test_instead_implicit_staticmethod_instance_only(self): 786 | """Instance methods can be decorated in isolation.""" 787 | 788 | tracker: t.List[dict] = [] 789 | 790 | def to_call_instead(decorated: Decorated) -> None: 791 | # Ensure this happens instead the wrapped call. 792 | tracker.append({1: decorated.args}) 793 | 794 | @instead(to_call_instead, instance_methods_only=True) 795 | class _ToDecorate: 796 | @staticmethod 797 | def to_call(*args): 798 | tracker.append({2: args}) 799 | 800 | _ToDecorate().to_call(1, 2) 801 | 802 | assert len(tracker) == 1 803 | assert tracker[0] == {2: (1, 2)} 804 | 805 | def test_instead_method_decorates_class_if_not_implicit(self): 806 | """Without implicit method decoration, the class init is decorated.""" 807 | 808 | tracker: t.List[dict] = [] 809 | 810 | def to_call_instead(decorated: Decorated) -> t.Any: 811 | # Ensure this happens instead the wrapped call. 812 | tracker.append({1: decorated.args}) 813 | return decorated(*decorated.args, **decorated.kwargs) 814 | 815 | @instead(to_call_instead, implicit_method_decoration=False) 816 | class _ToDecorate: 817 | def __init__(self): 818 | super().__init__() 819 | tracker.append({0: ()}) 820 | 821 | def to_call(self, *args): 822 | tracker.append({2: args}) 823 | 824 | @classmethod 825 | def to_call_cls(cls, *args): 826 | tracker.append({3: args}) 827 | 828 | @staticmethod 829 | def to_call_static(*args): 830 | tracker.append({4: args}) 831 | 832 | to_decorate = _ToDecorate() 833 | 834 | to_decorate.to_call(3, 4) 835 | to_decorate.to_call_cls(3, 4) 836 | to_decorate.to_call_static(3, 4) 837 | 838 | assert len(tracker) == 5 839 | assert tracker[0] == {1: ()} 840 | assert tracker[1] == {0: ()} 841 | assert tracker[2] == {2: (3, 4)} 842 | assert tracker[3] == {3: (3, 4)} 843 | assert tracker[4] == {4: (3, 4)} 844 | 845 | def test_instead_decorates_on_class_references(self): 846 | """Decorating class and staticmethods applies to the class ref.""" 847 | 848 | tracker: t.List[dict] = [] 849 | 850 | def to_call_instead(decorated: Decorated) -> None: 851 | # Ensure this happens instead the wrapped call. 852 | tracker.append({1: decorated.args}) 853 | 854 | @instead(to_call_instead) 855 | class _ToDecorate: 856 | @classmethod 857 | def to_call_cls(cls, *args): 858 | tracker.append({2: args}) 859 | 860 | @staticmethod 861 | def to_call_static(*args): 862 | tracker.append({3: args}) 863 | 864 | _ToDecorate.to_call_cls(1, 2) 865 | _ToDecorate.to_call_static(3, 4) 866 | 867 | assert len(tracker) == 2 868 | assert tracker[0] == {1: (1, 2)} 869 | assert tracker[1] == {1: (3, 4)} 870 | 871 | def test_instead_direct_method_decoration_equivalent(self): 872 | """Direct and implicit decoration work the same way.""" 873 | 874 | tracker: t.List[dict] = [] 875 | 876 | def to_call_instead(decorated: Decorated) -> None: 877 | # Ensure this happens instead the wrapped call. 878 | tracker.append({1: decorated.args}) 879 | 880 | class _ToDecorate: 881 | @instead(to_call_instead) 882 | def to_call(self, *args): 883 | tracker.append({2: args}) 884 | 885 | @classmethod 886 | @instead(to_call_instead) 887 | def to_call_cls(cls, *args): 888 | tracker.append({3: args}) 889 | 890 | @staticmethod 891 | @instead(to_call_instead) 892 | def to_call_static(*args): 893 | tracker.append({4: args}) 894 | 895 | instance = _ToDecorate() 896 | instance.to_call(1, 2) 897 | _ToDecorate().to_call_cls(3, 4) 898 | _ToDecorate().to_call_static(5, 6) 899 | 900 | assert len(tracker) == 3 901 | assert tracker[0] == {1: (instance, 1, 2)} 902 | assert tracker[1] == {1: (3, 4)} 903 | assert tracker[2] == {1: (5, 6)} 904 | 905 | 906 | class TestDecorator: 907 | """Test the generic before/after/instead decorator.""" 908 | 909 | def test_all_decorators(self): 910 | """Test adding one of each decorator type.""" 911 | 912 | tracker: t.List[dict] = [] 913 | 914 | def to_call_before(decorated: Decorated): 915 | tracker.append({1: decorated.args}) 916 | 917 | def to_call_after(decorated: Decorated): 918 | tracker.append({2: decorated.args}) 919 | 920 | def to_call_instead(decorated: Decorated): 921 | tracker.append({3: decorated.args}) 922 | return decorated(*decorated.args, **decorated.kwargs) 923 | 924 | @decorate( 925 | before=to_call_before, after=to_call_after, instead=to_call_instead 926 | ) 927 | def to_call(*args): 928 | tracker.append({4: args}) 929 | 930 | to_call(1, 2) 931 | 932 | assert len(tracker) == 4 933 | assert tracker[0] == {1: (1, 2)} # before 934 | assert tracker[1] == {3: (1, 2)} # instead 935 | assert tracker[2] == {4: (1, 2)} # wrapped 936 | assert tracker[3] == {2: (1, 2)} # after 937 | 938 | def test_all_decorators_constructed(self): 939 | """A decorator can be "pre-made" if needed""" 940 | 941 | tracker: t.List[dict] = [] 942 | 943 | def to_call_before(decorated: Decorated): 944 | tracker.append({1: decorated.args}) 945 | 946 | def to_call_after(decorated: Decorated): 947 | tracker.append({2: decorated.args}) 948 | 949 | def to_call_instead(decorated: Decorated): 950 | tracker.append({3: decorated.args}) 951 | return decorated(*decorated.args, **decorated.kwargs) 952 | 953 | pre_made = construct_decorator( 954 | before=to_call_before, after=to_call_after, instead=to_call_instead 955 | ) 956 | 957 | @pre_made() 958 | def to_call(*args): 959 | tracker.append({4: args}) 960 | 961 | to_call(1, 2) 962 | 963 | assert len(tracker) == 4 964 | assert tracker[0] == {1: (1, 2)} # before 965 | assert tracker[1] == {3: (1, 2)} # instead 966 | assert tracker[2] == {4: (1, 2)} # wrapped 967 | assert tracker[3] == {2: (1, 2)} # after 968 | 969 | def test_all_callables_get_extras(self): 970 | """All of the callables get extra kwargs.""" 971 | 972 | tracker: t.List[dict] = [] 973 | 974 | def to_call_before(decorated: Decorated, kwarg=None): 975 | tracker.append({1: kwarg}) 976 | 977 | def to_call_after(decorated: Decorated, kwarg=None): 978 | tracker.append({2: kwarg}) 979 | 980 | def to_call_instead(decorated: Decorated, kwarg=None): 981 | tracker.append({3: kwarg}) 982 | return decorated(*decorated.args, **decorated.kwargs) 983 | 984 | @decorate( 985 | before=to_call_before, 986 | after=to_call_after, 987 | instead=to_call_instead, 988 | kwarg=0, 989 | ) 990 | def to_call(*args): 991 | tracker.append({4: args}) 992 | 993 | to_call(1, 2) 994 | 995 | assert len(tracker) == 4 996 | assert tracker[0] == {1: 0} # before 997 | assert tracker[1] == {3: 0} # instead 998 | assert tracker[2] == {4: (1, 2)} # wrapped 999 | assert tracker[3] == {2: 0} # after 1000 | 1001 | def test_all_callables_get_specific_extras(self): 1002 | """Specific extras are passed appropriately.""" 1003 | 1004 | tracker: t.List[dict] = [] 1005 | 1006 | def to_call_before(decorated: Decorated, kwarg=None): 1007 | tracker.append({1: kwarg}) 1008 | 1009 | def to_call_after(decorated: Decorated, kwarg=None): 1010 | tracker.append({2: kwarg}) 1011 | 1012 | def to_call_instead(decorated: Decorated, kwarg=None): 1013 | tracker.append({3: kwarg}) 1014 | return decorated(*decorated.args, **decorated.kwargs) 1015 | 1016 | @decorate( 1017 | before=to_call_before, 1018 | before_kwargs={"kwarg": 0}, 1019 | after=to_call_after, 1020 | after_kwargs={"kwarg": 1}, 1021 | instead=to_call_instead, 1022 | instead_kwargs={"kwarg": 2}, 1023 | ) 1024 | def to_call(*args): 1025 | tracker.append({4: args}) 1026 | 1027 | to_call(1, 2) 1028 | 1029 | assert len(tracker) == 4 1030 | assert tracker[0] == {1: 0} # before 1031 | assert tracker[1] == {3: 2} # instead 1032 | assert tracker[2] == {4: (1, 2)} # wrapped 1033 | assert tracker[3] == {2: 1} # after 1034 | 1035 | def test_all_callables_specific_extras_overridden(self): 1036 | """General kwargs override specific ones.""" 1037 | 1038 | tracker: t.List[dict] = [] 1039 | 1040 | def to_call_before(decorated: Decorated, kwarg=None): 1041 | tracker.append({1: kwarg}) 1042 | 1043 | def to_call_after(decorated: Decorated, kwarg=None): 1044 | tracker.append({2: kwarg}) 1045 | 1046 | def to_call_instead(decorated: Decorated, kwarg=None): 1047 | tracker.append({3: kwarg}) 1048 | return decorated(*decorated.args, **decorated.kwargs) 1049 | 1050 | @decorate( 1051 | before=to_call_before, 1052 | before_kwargs={"kwarg": 0}, 1053 | after=to_call_after, 1054 | after_kwargs={"kwarg": 1}, 1055 | instead=to_call_instead, 1056 | instead_kwargs={"kwarg": 2}, 1057 | kwarg=3, 1058 | ) 1059 | def to_call(*args): 1060 | tracker.append({4: args}) 1061 | 1062 | to_call(1, 2) 1063 | 1064 | assert len(tracker) == 4 1065 | assert tracker[0] == {1: 3} # before 1066 | assert tracker[1] == {3: 3} # instead 1067 | assert tracker[2] == {4: (1, 2)} # wrapped 1068 | assert tracker[3] == {2: 3} # after 1069 | 1070 | def test_just_before(self): 1071 | """Test adding just before().""" 1072 | tracker: t.List[dict] = [] 1073 | 1074 | def to_call_before(decorated: Decorated): 1075 | tracker.append({1: decorated.args}) 1076 | 1077 | @decorate(before=to_call_before) 1078 | def to_call(*args): 1079 | tracker.append({2: args}) 1080 | 1081 | to_call(1, 2) 1082 | 1083 | assert len(tracker) == 2 1084 | assert tracker[0] == {1: (1, 2)} 1085 | assert tracker[1] == {2: (1, 2)} 1086 | 1087 | def test_just_after(self): 1088 | """Test adding just after().""" 1089 | tracker: t.List[dict] = [] 1090 | 1091 | def to_call_after(decorated: Decorated): 1092 | tracker.append({1: decorated.args}) 1093 | 1094 | @decorate(after=to_call_after) 1095 | def to_call(*args): 1096 | tracker.append({2: args}) 1097 | 1098 | to_call(1, 2) 1099 | 1100 | assert len(tracker) == 2 1101 | assert tracker[0] == {2: (1, 2)} 1102 | assert tracker[1] == {1: (1, 2)} 1103 | 1104 | def test_just_instead(self): 1105 | """Test adding just instead().""" 1106 | tracker: t.List[dict] = [] 1107 | 1108 | def to_call_instead(decorated: Decorated): 1109 | tracker.append({1: decorated.args}) 1110 | 1111 | @decorate(instead=to_call_instead) 1112 | def to_call(*args): 1113 | tracker.append({2: args}) 1114 | 1115 | to_call(1, 2) 1116 | 1117 | assert len(tracker) == 1 1118 | assert tracker[0] == {1: (1, 2)} 1119 | 1120 | def test_all_decorators_implicit_class(self): 1121 | """Test adding one of each decorator type to a class.""" 1122 | 1123 | tracker: t.List[dict] = [] 1124 | 1125 | def to_call_before(decorated: Decorated): 1126 | tracker.append({1: decorated.args}) 1127 | 1128 | def to_call_after(decorated: Decorated): 1129 | tracker.append({2: decorated.args}) 1130 | 1131 | def to_call_instead(decorated: Decorated): 1132 | tracker.append({3: decorated.args}) 1133 | return decorated(*decorated.args, **decorated.kwargs) 1134 | 1135 | @decorate( 1136 | before=to_call_before, after=to_call_after, instead=to_call_instead 1137 | ) 1138 | class _ToDecorate: 1139 | def to_call(self, *args): 1140 | tracker.append({4: args}) 1141 | 1142 | _ToDecorate().to_call(1, 2) 1143 | 1144 | assert len(tracker) == 4 1145 | assert tracker[0] == {1: (1, 2)} # before 1146 | assert tracker[1] == {3: (1, 2)} # instead 1147 | assert tracker[2] == {4: (1, 2)} # wrapped 1148 | assert tracker[3] == {2: (1, 2)} # after 1149 | 1150 | def test_at_least_one_callable_must_be_specified(self): 1151 | """Not specifying any callables does not work.""" 1152 | with pytest.raises(ValueError): 1153 | 1154 | @decorate() 1155 | def _fn(): 1156 | pass 1157 | 1158 | 1159 | @pytest.mark.parametrize("decorator", [before, after, instead]) 1160 | def test_extras_persistence(decorator): 1161 | """Test the persistence across calls of extras""" 1162 | 1163 | def memo_func(_decorated, memo): 1164 | memo.append("called") 1165 | 1166 | memo: list = [] 1167 | 1168 | decorated = Mock(return_value=None) 1169 | 1170 | decorated.__name__ = str("decorated_mock") 1171 | 1172 | decorated = decorator(memo_func, memo=memo,)(decorated) 1173 | 1174 | for _ in range(5): 1175 | decorated() 1176 | 1177 | assert len(memo) == 5 1178 | 1179 | 1180 | @pytest.mark.parametrize("decorator", [before, after, instead]) 1181 | def test_extras_persistence_class(decorator): 1182 | """Test persistence of extras when decorating a class""" 1183 | 1184 | def memo_func(_decorated, memo): 1185 | memo.append("called") 1186 | 1187 | memo: list = [] 1188 | 1189 | @decorator( 1190 | memo_func, memo=memo, 1191 | ) 1192 | class GreatClass(object): 1193 | def awesome_method(self): 1194 | pass 1195 | 1196 | @classmethod 1197 | def classy_method(cls): 1198 | pass 1199 | 1200 | @staticmethod 1201 | def stately_method(): 1202 | pass 1203 | 1204 | @property 1205 | def prop(self): 1206 | return "prop" 1207 | 1208 | gc = GreatClass() 1209 | 1210 | for _ in range(2): 1211 | gc.awesome_method() 1212 | 1213 | assert len(memo) == 2 1214 | 1215 | assert gc.prop 1216 | 1217 | for _ in range(2): 1218 | GreatClass.classy_method() 1219 | 1220 | assert len(memo) == 4 1221 | 1222 | for _ in range(2): 1223 | gc.classy_method() 1224 | 1225 | assert len(memo) == 6 1226 | 1227 | for _ in range(2): 1228 | GreatClass.stately_method() 1229 | 1230 | assert len(memo) == 8 1231 | 1232 | for _ in range(2): 1233 | gc.stately_method() 1234 | 1235 | assert len(memo) == 10 1236 | 1237 | 1238 | @pytest.mark.parametrize("decorator", [before, after, instead]) 1239 | def test_extras_persistence_class_inst_only(decorator): 1240 | """Test persistence of extras, instance methods only""" 1241 | 1242 | def memo_func(_decorated, memo): 1243 | memo.append("called") 1244 | 1245 | memo: list = [] 1246 | 1247 | @decorator( 1248 | memo_func, instance_methods_only=True, memo=memo, 1249 | ) 1250 | class GreatClass(object): 1251 | def awesome_method(self): 1252 | pass 1253 | 1254 | @classmethod 1255 | def classy_method(cls): 1256 | pass 1257 | 1258 | @staticmethod 1259 | def stately_method(): 1260 | pass 1261 | 1262 | @property 1263 | def prop(self): 1264 | return "prop" 1265 | 1266 | gc = GreatClass() 1267 | 1268 | for _ in range(2): 1269 | gc.awesome_method() 1270 | 1271 | assert len(memo) == 2 1272 | 1273 | for _ in range(2): 1274 | GreatClass.classy_method() 1275 | 1276 | assert gc.prop 1277 | 1278 | assert len(memo) == 2 1279 | 1280 | for _ in range(2): 1281 | gc.classy_method() 1282 | 1283 | assert len(memo) == 2 1284 | 1285 | for _ in range(2): 1286 | GreatClass.stately_method() 1287 | 1288 | assert len(memo) == 2 1289 | 1290 | for _ in range(2): 1291 | gc.stately_method() 1292 | 1293 | assert len(memo) == 2 1294 | -------------------------------------------------------------------------------- /tests/decorators/test_ready_to_wear.py: -------------------------------------------------------------------------------- 1 | """Test ready-to-use decorators.""" 2 | 3 | import typing as t 4 | from logging import getLogger 5 | from time import sleep 6 | from unittest.mock import Mock, call 7 | 8 | import pytest 9 | 10 | from pydecor.caches import FIFOCache, LRUCache, TimedCache 11 | from pydecor.constants import LOG_CALL_FMT_STR 12 | from pydecor.decorators import ( 13 | log_call, 14 | intercept, 15 | memoize, 16 | ) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "raises, catch, reraise, include_handler", 21 | [ 22 | (Exception, Exception, ValueError, False), 23 | (Exception, Exception, ValueError, True), 24 | (Exception, Exception, True, True), 25 | (Exception, Exception, True, False), 26 | (None, Exception, ValueError, False), 27 | (None, Exception, ValueError, True), 28 | (Exception, Exception, None, False), 29 | (Exception, Exception, None, True), 30 | (Exception, RuntimeError, ValueError, False), # won't catch 31 | (Exception, RuntimeError, ValueError, True), # won't catch 32 | ], 33 | ) 34 | def test_intercept(raises, catch, reraise, include_handler): 35 | """Test the intercept decorator""" 36 | wrapped = Mock() 37 | 38 | wrapped.__name__ = str("wrapped") 39 | 40 | if raises is not None: 41 | wrapped.side_effect = raises 42 | 43 | handler = Mock(name="handler") if include_handler else None 44 | 45 | if handler is not None: 46 | handler.__name__ = str("handler") 47 | 48 | fn = intercept(catch=catch, reraise=reraise, handler=handler)(wrapped) 49 | 50 | will_catch = raises and issubclass(raises, catch) 51 | 52 | if reraise and will_catch: 53 | to_be_raised = raises if reraise is True else reraise 54 | with pytest.raises(to_be_raised): 55 | fn() 56 | elif raises and not will_catch: 57 | with pytest.raises(raises): 58 | fn() 59 | else: 60 | fn() 61 | 62 | if handler is not None and will_catch: 63 | # pylint: disable=unsubscriptable-object 64 | called_with = handler.call_args[0][0] 65 | # pylint: enable=unsubscriptable-object 66 | assert isinstance(called_with, raises) 67 | 68 | if handler is not None and not will_catch: 69 | handler.assert_not_called() 70 | 71 | wrapped.assert_called_once_with(*(), **{}) # type: ignore 72 | 73 | 74 | def test_intercept_method(): 75 | """Test decorating an instance method with intercept.""" 76 | 77 | calls = [] 78 | 79 | def _handler(exc): 80 | calls.append(exc) 81 | 82 | class SomeClass: 83 | @intercept(handler=_handler) 84 | def it_raises(self, val): 85 | raise ValueError(val) 86 | 87 | SomeClass().it_raises("a") 88 | assert len(calls) == 1 89 | assert isinstance(calls[0], ValueError) 90 | 91 | 92 | def test_log_call(): 93 | """Test the log_call decorator""" 94 | exp_logger = getLogger(__name__) 95 | exp_logger.debug = Mock() # type: ignore 96 | 97 | @log_call(level="debug") 98 | def func(*args, **kwargs): 99 | return "foo" 100 | 101 | call_args = ("a",) 102 | call_kwargs = {"b": "c"} 103 | 104 | call_res = func(*call_args, **call_kwargs) 105 | 106 | exp_msg = LOG_CALL_FMT_STR.format( 107 | name="func", args=call_args, kwargs=call_kwargs, result=call_res 108 | ) 109 | 110 | exp_logger.debug.assert_called_once_with(exp_msg) 111 | 112 | 113 | class TestMemoization: 114 | """Tests for memoization""" 115 | 116 | # (args, kwargs) 117 | memoizable_calls: t.Tuple[t.Tuple, ...] = ( 118 | (("a", "b"), {"c": "d"}), 119 | ((["a", "b", "c"],), {"c": "d"}), 120 | ((lambda x: "foo",), {"c": lambda y: "bar"}), 121 | (({"a": "a"},), {"c": "d"}), 122 | ((type(str("A"), (object,), {})(),), {}), 123 | ((), {}), 124 | ((1, 2, 3), {}), 125 | ) 126 | 127 | @pytest.mark.parametrize("args, kwargs", memoizable_calls) 128 | def test_memoize_basic(self, args, kwargs): 129 | """Test basic use of the memoize decorator""" 130 | tracker = Mock(return_value="foo") 131 | 132 | @memoize() 133 | def func(*args, **kwargs): 134 | return tracker(args, kwargs) 135 | 136 | assert func(*args, **kwargs) == "foo" 137 | tracker.assert_called_once_with(args, kwargs) 138 | 139 | assert func(*args, **kwargs) == "foo" 140 | assert len(tracker.mock_calls) == 1 141 | 142 | def test_memoize_lru(self): 143 | """Test removal of least-recently-used items""" 144 | call_list = tuple(range(5)) # 0-4 145 | tracker = Mock() 146 | 147 | @memoize(keep=5, cache_class=LRUCache) 148 | def func(val): 149 | tracker(val) 150 | return val 151 | 152 | for val in call_list: 153 | func(val) 154 | 155 | # LRU: 0 1 2 3 4 156 | assert len(tracker.mock_calls) == len(call_list) 157 | for val in call_list: 158 | assert call(val) in tracker.mock_calls 159 | 160 | # call with all the same args 161 | for val in call_list: 162 | func(val) 163 | 164 | # no new calls, lru order should be same 165 | # LRU: 0 1 2 3 4 166 | assert len(tracker.mock_calls) == len(call_list) 167 | for val in call_list: 168 | assert call(val) in tracker.mock_calls 169 | 170 | # add new value, popping least-recently-used (0) 171 | # LRU: 1 2 3 4 5 172 | func(5) 173 | assert len(tracker.mock_calls) == len(call_list) + 1 174 | assert tracker.mock_calls[-1] == call(5) # most recent call 175 | 176 | # Re-call with 0, asserting that we call the func again, 177 | # and dropping 1 178 | # LRU: 2 3 4 5 0 179 | func(0) 180 | assert len(tracker.mock_calls) == len(call_list) + 2 181 | assert tracker.mock_calls[-1] == call(0) # most recent call 182 | 183 | # Let's ensure that using something rearranges it 184 | func(2) 185 | # LRU: 3 4 5 0 2 186 | # no new calls 187 | assert len(tracker.mock_calls) == len(call_list) + 2 188 | assert tracker.mock_calls[-1] == call(0) # most recent call 189 | 190 | # Let's put another new value into the cache 191 | func(6) 192 | # LRU: 4 5 0 2 6 193 | assert len(tracker.mock_calls) == len(call_list) + 3 194 | assert tracker.mock_calls[-1] == call(6) 195 | 196 | # Assert that 2 hasn't been dropped from the list, like it 197 | # would have been if we hadn't called it before 6 198 | func(2) 199 | # LRU: 4 5 0 6 2 200 | assert len(tracker.mock_calls) == len(call_list) + 3 201 | assert tracker.mock_calls[-1] == call(6) 202 | 203 | def test_memoize_fifo(self): 204 | """Test using the FIFO cache""" 205 | call_list = tuple(range(5)) # 0-4 206 | tracker = Mock() 207 | 208 | @memoize(keep=5, cache_class=FIFOCache) 209 | def func(val): 210 | tracker(val) 211 | return val 212 | 213 | for val in call_list: 214 | func(val) 215 | 216 | # Cache: 0 1 2 3 4 217 | assert len(tracker.mock_calls) == len(call_list) 218 | for val in call_list: 219 | assert call(val) in tracker.mock_calls 220 | 221 | # call with all the same args 222 | for val in call_list: 223 | func(val) 224 | 225 | # no new calls, cache still the same 226 | # Cache: 0 1 2 3 4 227 | assert len(tracker.mock_calls) == len(call_list) 228 | for val in call_list: 229 | assert call(val) in tracker.mock_calls 230 | 231 | # add new value, popping first in (0) 232 | # Cache: 1 2 3 4 5 233 | func(5) 234 | assert len(tracker.mock_calls) == len(call_list) + 1 235 | assert tracker.mock_calls[-1] == call(5) # most recent call 236 | 237 | # Assert 5 doesn't yield a new call 238 | func(5) 239 | assert len(tracker.mock_calls) == len(call_list) + 1 240 | assert tracker.mock_calls[-1] == call(5) # most recent call 241 | 242 | # Re-call with 0, asserting that we call the func again, 243 | # and dropping 1 244 | # Cache: 2 3 4 5 0 245 | func(0) 246 | assert len(tracker.mock_calls) == len(call_list) + 2 247 | assert tracker.mock_calls[-1] == call(0) # most recent call 248 | 249 | # Assert neither 0 nor 5 yield new calls 250 | func(0) 251 | func(5) 252 | assert len(tracker.mock_calls) == len(call_list) + 2 253 | assert tracker.mock_calls[-1] == call(0) # most recent call 254 | 255 | def test_memoization_timed(self): 256 | """Test timed memoization""" 257 | time = 0.005 258 | tracker = Mock() 259 | 260 | @memoize(keep=time, cache_class=TimedCache) 261 | def func(val): 262 | tracker(val) 263 | return val 264 | 265 | assert func(1) == 1 266 | assert tracker.mock_calls == [call(1)] 267 | assert func(1) == 1 268 | assert tracker.mock_calls == [call(1)] 269 | sleep(time) 270 | assert func(1) == 1 271 | assert tracker.mock_calls == [call(1), call(1)] 272 | -------------------------------------------------------------------------------- /tests/test_caches.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Tests for caches 4 | """ 5 | 6 | from time import sleep 7 | 8 | import pytest 9 | 10 | from pydecor.caches import FIFOCache, LRUCache, TimedCache 11 | 12 | 13 | class TestLRU: 14 | """Test the LRU cache""" 15 | 16 | def test_unsized(self): 17 | """Test that not specifying a size lets the cache grow""" 18 | cache = LRUCache() 19 | for i in range(500): 20 | cache[i] = i 21 | for i in range(500): 22 | assert i in cache 23 | assert cache[i] == i 24 | 25 | def test_sized_no_reuse(self): 26 | """Test basic LIFO functionality with all new values""" 27 | cache = LRUCache(max_size=5) 28 | for i in range(5): 29 | cache[i] = i 30 | for i in range(5): 31 | assert i in cache 32 | assert cache[i] == i 33 | for i in range(5, 10): 34 | cache[i] = i 35 | assert i in cache 36 | assert cache[i] == i 37 | assert i - 5 not in cache 38 | with pytest.raises(KeyError): 39 | assert cache[i - 5] 40 | 41 | def test_sized_with_reuse(self): 42 | """Test LRU functionality""" 43 | cache = LRUCache(max_size=3) 44 | for i in range(3): 45 | cache[i] = i 46 | # LRU: 0 1 2 47 | for i in range(3): 48 | assert i in cache 49 | assert cache[i] == i 50 | # LRU: 0 1 2 51 | 52 | cache[3] = 3 53 | # LRU: 1 2 3 54 | 55 | assert 0 not in cache 56 | with pytest.raises(KeyError): 57 | assert cache[0] 58 | 59 | for i in range(1, 4): 60 | assert i in cache 61 | assert cache[i] == i 62 | 63 | # Test re-ording with getitem 64 | assert cache[1] 65 | # LRU: 2 3 1 66 | 67 | cache[0] = 0 68 | # LRU: 3 1 0 69 | 70 | assert 2 not in cache 71 | with pytest.raises(KeyError): 72 | assert cache[2] 73 | 74 | for i in 3, 1, 0: 75 | assert i in cache 76 | assert cache[i] == i 77 | 78 | # Test re-ording with setitem 79 | cache[3] = 3 80 | # LRU: 1 0 3 81 | 82 | for i in 1, 0, 3: 83 | assert i in cache 84 | assert cache[i] == i 85 | 86 | cache[2] = 2 87 | # LRU: 0 3 2 88 | 89 | for i in 0, 3, 2: 90 | assert i in cache 91 | assert cache[i] == i 92 | 93 | assert 1 not in cache 94 | with pytest.raises(KeyError): 95 | assert cache[1] 96 | 97 | 98 | class TestFIFO: 99 | """Test the LRU cache""" 100 | 101 | def test_unsized(self): 102 | """Test that not specifying a size lets the cache grow""" 103 | cache = FIFOCache() 104 | for i in range(500): 105 | cache[i] = i 106 | for i in range(500): 107 | assert i in cache 108 | assert cache[i] == i 109 | 110 | def test_sized_no_reuse(self): 111 | """Test basic LIFO functionality with all new values""" 112 | cache = FIFOCache(max_size=5) 113 | for i in range(5): 114 | cache[i] = i 115 | for i in range(5): 116 | assert i in cache 117 | assert cache[i] == i 118 | for i in range(5, 10): 119 | cache[i] = i 120 | assert i in cache 121 | assert cache[i] == i 122 | assert i - 5 not in cache 123 | with pytest.raises(KeyError): 124 | assert cache[i - 5] 125 | 126 | def test_sized_with_reuse(self): 127 | """Test LRU functionality""" 128 | cache = FIFOCache(max_size=3) 129 | for i in range(3): 130 | cache[i] = i 131 | # Cache: 0 1 2 132 | for i in range(3): 133 | assert i in cache 134 | assert cache[i] == i 135 | # Cache: 0 1 2 136 | 137 | cache[3] = 3 138 | # Cache: 1 2 3 139 | 140 | assert 0 not in cache 141 | with pytest.raises(KeyError): 142 | assert cache[0] 143 | 144 | for i in range(1, 4): 145 | assert i in cache 146 | assert cache[i] == i 147 | 148 | assert cache[1] 149 | # Cache: 1 2 3 150 | 151 | cache[0] = 0 152 | # Cache: 2 3 0 153 | 154 | assert 1 not in cache 155 | with pytest.raises(KeyError): 156 | assert cache[1] 157 | 158 | for i in 2, 3, 0: 159 | assert i in cache 160 | assert cache[i] == i 161 | 162 | 163 | class TestTimedCache: 164 | def test_untimed(self): 165 | """Test that not specifying a size lets the cache grow""" 166 | cache = TimedCache() 167 | for i in range(500): 168 | cache[i] = i 169 | for i in range(500): 170 | assert i in cache 171 | assert cache[i] == i 172 | 173 | def test_timed(self): 174 | """Test that entries are removed if accessed after max_age""" 175 | time = 0.001 176 | cache = TimedCache(max_age=time) 177 | 178 | cache[1] = 1 179 | assert 1 in cache 180 | sleep(time) 181 | assert 1 not in cache 182 | with pytest.raises(KeyError): 183 | assert cache[1] 184 | 185 | for i in range(50): 186 | cache[i] = i 187 | assert i in cache 188 | assert cache[i] == i 189 | sleep(time) 190 | for i in range(50): 191 | assert i not in cache 192 | with pytest.raises(KeyError): 193 | assert cache[i] 194 | 195 | def test_timed_reset(self): 196 | """Test that resetting entries resets their time""" 197 | time = 0.005 198 | cache = TimedCache(max_age=time) 199 | 200 | cache[1] = 1 201 | assert 1 in cache 202 | assert cache[1] == 1 203 | sleep(time / 2) 204 | assert 1 in cache 205 | assert cache[1] == 1 206 | cache[1] = 1 207 | sleep(time / 2) 208 | assert 1 in cache 209 | assert cache[1] == 1 210 | sleep(time / 2) 211 | assert 1 not in cache 212 | with pytest.raises(KeyError): 213 | assert cache[1] 214 | -------------------------------------------------------------------------------- /tests/test_functions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Tests for the functions module 4 | """ 5 | 6 | try: 7 | from unittest.mock import Mock 8 | except ImportError: 9 | from mock import Mock 10 | 11 | from functools import partial 12 | from logging import getLogger 13 | 14 | 15 | import pytest 16 | 17 | 18 | from pydecor.decorators import Decorated 19 | from pydecor.constants import LOG_CALL_FMT_STR 20 | from pydecor.functions import intercept, log_call 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "raises, catch, reraise, include_handler", 25 | [ 26 | (Exception, Exception, ValueError, False), 27 | (Exception, Exception, ValueError, True), 28 | (None, Exception, ValueError, False), 29 | (None, Exception, ValueError, True), 30 | (Exception, Exception, None, False), 31 | (Exception, Exception, None, True), 32 | (Exception, RuntimeError, ValueError, False), # won't catch 33 | (Exception, RuntimeError, ValueError, True), # won't catch 34 | ], 35 | ) 36 | def test_interceptor(raises, catch, reraise, include_handler): 37 | """Test the intercept function""" 38 | wrapped = Mock() 39 | wrapped.__name__ = "intercept_mock" 40 | if raises is not None: 41 | wrapped.side_effect = raises 42 | 43 | handler = Mock() if include_handler else None 44 | 45 | decorated = Decorated(wrapped, (), {}) 46 | 47 | fn = partial( 48 | intercept, decorated, catch=catch, reraise=reraise, handler=handler 49 | ) 50 | 51 | will_catch = raises and issubclass(raises, catch) 52 | 53 | if reraise and will_catch: 54 | with pytest.raises(reraise): 55 | fn() 56 | elif raises and not will_catch: 57 | with pytest.raises(raises): 58 | fn() 59 | else: 60 | fn() 61 | 62 | if handler is not None and will_catch: 63 | called_with = handler.call_args[0][0] 64 | assert isinstance(called_with, raises) 65 | 66 | if handler is not None and not will_catch: 67 | handler.assert_not_called() 68 | 69 | wrapped.assert_called_once_with(*(), **{}) # type: ignore 70 | 71 | 72 | def test_log_call(): 73 | """Test automatic logging""" 74 | exp_logger = getLogger(__name__) 75 | exp_logger.debug = Mock() # type: ignore 76 | 77 | def func(*args, **kwargs): 78 | return "foo" 79 | 80 | call_args = ("a",) 81 | call_kwargs = {"b": "c"} 82 | decorated = Decorated(func, call_args, call_kwargs) 83 | call_res = decorated(*decorated.args, **decorated.kwargs) 84 | 85 | log_call(decorated, level="debug") 86 | 87 | exp_msg = LOG_CALL_FMT_STR.format( 88 | name="func", args=call_args, kwargs=call_kwargs, result=call_res 89 | ) 90 | 91 | exp_logger.debug.assert_called_once_with(exp_msg) 92 | -------------------------------------------------------------------------------- /tests/test_memoization.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Tests for memoization functions 4 | """ 5 | 6 | import pytest 7 | 8 | from pydecor._memoization import hashable 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "item", 13 | [ 14 | "foo", 15 | 12, 16 | 12.5, 17 | 7e7, 18 | {"foo": "bar"}, 19 | object(), 20 | type("a", (object,), {}), 21 | type("a", (object,), {})(), 22 | lambda x: "foo", 23 | {"a", "b", "c"}, 24 | ("a", "b", "c"), 25 | ["a", "b", "c"], 26 | ("a", {"b": "c"}, ["d"]), 27 | ["a", ("b", "c"), {"d": "e"}], 28 | ], 29 | ) 30 | def test_hashable(item): 31 | """Test getting a hashable verison of an item 32 | 33 | Asserts that the hash method does not error, which it does 34 | if the returned item is unhashable 35 | """ 36 | hash(hashable(item)) 37 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, pypy3 3 | 4 | [testenv] 5 | 6 | setenv = 7 | PACKAGE_NAME = pydecor 8 | 9 | whitelist_externals = 10 | make 11 | 12 | commands = 13 | make test 14 | --------------------------------------------------------------------------------