├── .bumpversion.cfg ├── .cookiecutterrc ├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── github-actions.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── SECURITY.md ├── ci ├── bootstrap.py ├── requirements.txt └── templates │ └── .github │ └── workflows │ └── github-actions.yml ├── docs ├── authors.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── development.rst ├── examples.rst ├── faq.rst ├── index.rst ├── installation.rst ├── introduction.rst ├── presentations │ ├── pycon.se-lightningtalk.pdf │ └── pycon.se-lightningtalk.rst ├── rationale.rst ├── readme.rst ├── reference │ ├── aspectlib.contrib.rst │ ├── aspectlib.debug.rst │ ├── aspectlib.rst │ ├── aspectlib.test.rst │ └── index.rst ├── requirements.txt ├── spelling_wordlist.txt ├── testing.rst └── todo.rst ├── pyproject.toml ├── pytest.ini ├── setup.py ├── src └── aspectlib │ ├── __init__.py │ ├── contrib.py │ ├── debug.py │ ├── pytestsupport.py │ ├── test.py │ └── utils.py ├── tests ├── conftest.py ├── mymod.py ├── test_aspectlib.py ├── test_aspectlib_debug.py ├── test_aspectlib_py3.py ├── test_aspectlib_py37.py ├── test_aspectlib_test.py ├── test_contrib.py ├── test_integrations.py ├── test_pkg1 │ ├── __init__.py │ └── test_pkg2 │ │ ├── __init__.py │ │ └── test_mod.py └── test_pytestsupport.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.0.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file (badge):README.rst] 11 | search = /v{current_version}.svg 12 | replace = /v{new_version}.svg 13 | 14 | [bumpversion:file (link):README.rst] 15 | search = /v{current_version}...main 16 | replace = /v{new_version}...main 17 | 18 | [bumpversion:file:docs/conf.py] 19 | search = version = release = '{current_version}' 20 | replace = version = release = '{new_version}' 21 | 22 | [bumpversion:file:src/aspectlib/__init__.py] 23 | search = __version__ = '{current_version}' 24 | replace = __version__ = '{new_version}' 25 | 26 | [bumpversion:file:.cookiecutterrc] 27 | search = version: {current_version} 28 | replace = version: {new_version} 29 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # Generated by cookiepatcher, a small shim around cookiecutter (pip install cookiepatcher) 2 | 3 | default_context: 4 | c_extension_optional: 'no' 5 | c_extension_support: 'no' 6 | codacy: 'no' 7 | codacy_projectid: '-' 8 | codeclimate: 'no' 9 | codecov: 'yes' 10 | command_line_interface: 'no' 11 | command_line_interface_bin_name: '-' 12 | coveralls: 'yes' 13 | distribution_name: aspectlib 14 | email: contact@ionelmc.ro 15 | formatter_quote_style: single 16 | full_name: Ionel Cristian Mărieș 17 | function_name: compute 18 | github_actions: 'yes' 19 | github_actions_osx: 'no' 20 | github_actions_windows: 'no' 21 | license: BSD 2-Clause License 22 | module_name: core 23 | package_name: aspectlib 24 | pre_commit: 'yes' 25 | project_name: Aspectlib 26 | project_short_description: '``aspectlib`` is an aspect-oriented programming, monkey-patch and decorators library. It is useful when changing' 27 | pypi_badge: 'yes' 28 | pypi_disable_upload: 'no' 29 | release_date: '2022-10-20' 30 | repo_hosting: github.com 31 | repo_hosting_domain: github.com 32 | repo_main_branch: main 33 | repo_name: python-aspectlib 34 | repo_username: ionelmc 35 | scrutinizer: 'no' 36 | setup_py_uses_setuptools_scm: 'no' 37 | sphinx_docs: 'yes' 38 | sphinx_docs_hosting: https://python-aspectlib.readthedocs.io/ 39 | sphinx_doctest: 'yes' 40 | sphinx_theme: furo 41 | test_matrix_separate_coverage: 'yes' 42 | tests_inside_package: 'no' 43 | version: 2.0.0 44 | version_manager: bump2version 45 | website: http://blog.ionelmc.ro 46 | year_from: '2014' 47 | year_to: '2024' 48 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = src 3 | 4 | [run] 5 | branch = true 6 | source = 7 | src 8 | tests 9 | parallel = true 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | omit = *migrations* 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | # Use Unix-style newlines for most files (except Windows files, see below). 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | insert_final_newline = true 10 | indent_size = 4 11 | charset = utf-8 12 | 13 | [*.{bat,cmd,ps1}] 14 | end_of_line = crlf 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | 19 | [*.tsv] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: ${{ matrix.name }} 6 | runs-on: ${{ matrix.os }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.11' 14 | toxpython: 'python3.11' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | - name: 'docs' 18 | python: '3.11' 19 | toxpython: 'python3.11' 20 | tox_env: 'docs' 21 | os: 'ubuntu-latest' 22 | - name: 'py38-cover-release (ubuntu)' 23 | python: '3.8' 24 | toxpython: 'python3.8' 25 | python_arch: 'x64' 26 | tox_env: 'py38-cover-release' 27 | os: 'ubuntu-latest' 28 | - name: 'py38-cover-debug (ubuntu)' 29 | python: '3.8' 30 | toxpython: 'python3.8' 31 | python_arch: 'x64' 32 | tox_env: 'py38-cover-debug' 33 | os: 'ubuntu-latest' 34 | - name: 'py38-nocov-release (ubuntu)' 35 | python: '3.8' 36 | toxpython: 'python3.8' 37 | python_arch: 'x64' 38 | tox_env: 'py38-nocov-release' 39 | os: 'ubuntu-latest' 40 | - name: 'py38-nocov-debug (ubuntu)' 41 | python: '3.8' 42 | toxpython: 'python3.8' 43 | python_arch: 'x64' 44 | tox_env: 'py38-nocov-debug' 45 | os: 'ubuntu-latest' 46 | - name: 'py39-cover-release (ubuntu)' 47 | python: '3.9' 48 | toxpython: 'python3.9' 49 | python_arch: 'x64' 50 | tox_env: 'py39-cover-release' 51 | os: 'ubuntu-latest' 52 | - name: 'py39-cover-debug (ubuntu)' 53 | python: '3.9' 54 | toxpython: 'python3.9' 55 | python_arch: 'x64' 56 | tox_env: 'py39-cover-debug' 57 | os: 'ubuntu-latest' 58 | - name: 'py39-nocov-release (ubuntu)' 59 | python: '3.9' 60 | toxpython: 'python3.9' 61 | python_arch: 'x64' 62 | tox_env: 'py39-nocov-release' 63 | os: 'ubuntu-latest' 64 | - name: 'py39-nocov-debug (ubuntu)' 65 | python: '3.9' 66 | toxpython: 'python3.9' 67 | python_arch: 'x64' 68 | tox_env: 'py39-nocov-debug' 69 | os: 'ubuntu-latest' 70 | - name: 'py310-cover-release (ubuntu)' 71 | python: '3.10' 72 | toxpython: 'python3.10' 73 | python_arch: 'x64' 74 | tox_env: 'py310-cover-release' 75 | os: 'ubuntu-latest' 76 | - name: 'py310-cover-debug (ubuntu)' 77 | python: '3.10' 78 | toxpython: 'python3.10' 79 | python_arch: 'x64' 80 | tox_env: 'py310-cover-debug' 81 | os: 'ubuntu-latest' 82 | - name: 'py310-nocov-release (ubuntu)' 83 | python: '3.10' 84 | toxpython: 'python3.10' 85 | python_arch: 'x64' 86 | tox_env: 'py310-nocov-release' 87 | os: 'ubuntu-latest' 88 | - name: 'py310-nocov-debug (ubuntu)' 89 | python: '3.10' 90 | toxpython: 'python3.10' 91 | python_arch: 'x64' 92 | tox_env: 'py310-nocov-debug' 93 | os: 'ubuntu-latest' 94 | - name: 'py311-cover-release (ubuntu)' 95 | python: '3.11' 96 | toxpython: 'python3.11' 97 | python_arch: 'x64' 98 | tox_env: 'py311-cover-release' 99 | os: 'ubuntu-latest' 100 | - name: 'py311-cover-debug (ubuntu)' 101 | python: '3.11' 102 | toxpython: 'python3.11' 103 | python_arch: 'x64' 104 | tox_env: 'py311-cover-debug' 105 | os: 'ubuntu-latest' 106 | - name: 'py311-nocov-release (ubuntu)' 107 | python: '3.11' 108 | toxpython: 'python3.11' 109 | python_arch: 'x64' 110 | tox_env: 'py311-nocov-release' 111 | os: 'ubuntu-latest' 112 | - name: 'py311-nocov-debug (ubuntu)' 113 | python: '3.11' 114 | toxpython: 'python3.11' 115 | python_arch: 'x64' 116 | tox_env: 'py311-nocov-debug' 117 | os: 'ubuntu-latest' 118 | - name: 'py312-cover-release (ubuntu)' 119 | python: '3.12' 120 | toxpython: 'python3.12' 121 | python_arch: 'x64' 122 | tox_env: 'py312-cover-release' 123 | os: 'ubuntu-latest' 124 | - name: 'py312-cover-debug (ubuntu)' 125 | python: '3.12' 126 | toxpython: 'python3.12' 127 | python_arch: 'x64' 128 | tox_env: 'py312-cover-debug' 129 | os: 'ubuntu-latest' 130 | - name: 'py312-nocov-release (ubuntu)' 131 | python: '3.12' 132 | toxpython: 'python3.12' 133 | python_arch: 'x64' 134 | tox_env: 'py312-nocov-release' 135 | os: 'ubuntu-latest' 136 | - name: 'py312-nocov-debug (ubuntu)' 137 | python: '3.12' 138 | toxpython: 'python3.12' 139 | python_arch: 'x64' 140 | tox_env: 'py312-nocov-debug' 141 | os: 'ubuntu-latest' 142 | - name: 'pypy37-cover-release (ubuntu)' 143 | python: 'pypy-3.7' 144 | toxpython: 'pypy3.7' 145 | python_arch: 'x64' 146 | tox_env: 'pypy37-cover-release' 147 | os: 'ubuntu-latest' 148 | - name: 'pypy37-cover-debug (ubuntu)' 149 | python: 'pypy-3.7' 150 | toxpython: 'pypy3.7' 151 | python_arch: 'x64' 152 | tox_env: 'pypy37-cover-debug' 153 | os: 'ubuntu-latest' 154 | - name: 'pypy37-nocov-release (ubuntu)' 155 | python: 'pypy-3.7' 156 | toxpython: 'pypy3.7' 157 | python_arch: 'x64' 158 | tox_env: 'pypy37-nocov-release' 159 | os: 'ubuntu-latest' 160 | - name: 'pypy37-nocov-debug (ubuntu)' 161 | python: 'pypy-3.7' 162 | toxpython: 'pypy3.7' 163 | python_arch: 'x64' 164 | tox_env: 'pypy37-nocov-debug' 165 | os: 'ubuntu-latest' 166 | - name: 'pypy38-cover-release (ubuntu)' 167 | python: 'pypy-3.8' 168 | toxpython: 'pypy3.8' 169 | python_arch: 'x64' 170 | tox_env: 'pypy38-cover-release' 171 | os: 'ubuntu-latest' 172 | - name: 'pypy38-cover-debug (ubuntu)' 173 | python: 'pypy-3.8' 174 | toxpython: 'pypy3.8' 175 | python_arch: 'x64' 176 | tox_env: 'pypy38-cover-debug' 177 | os: 'ubuntu-latest' 178 | - name: 'pypy38-nocov-release (ubuntu)' 179 | python: 'pypy-3.8' 180 | toxpython: 'pypy3.8' 181 | python_arch: 'x64' 182 | tox_env: 'pypy38-nocov-release' 183 | os: 'ubuntu-latest' 184 | - name: 'pypy38-nocov-debug (ubuntu)' 185 | python: 'pypy-3.8' 186 | toxpython: 'pypy3.8' 187 | python_arch: 'x64' 188 | tox_env: 'pypy38-nocov-debug' 189 | os: 'ubuntu-latest' 190 | - name: 'pypy39-cover-release (ubuntu)' 191 | python: 'pypy-3.9' 192 | toxpython: 'pypy3.9' 193 | python_arch: 'x64' 194 | tox_env: 'pypy39-cover-release' 195 | os: 'ubuntu-latest' 196 | - name: 'pypy39-cover-debug (ubuntu)' 197 | python: 'pypy-3.9' 198 | toxpython: 'pypy3.9' 199 | python_arch: 'x64' 200 | tox_env: 'pypy39-cover-debug' 201 | os: 'ubuntu-latest' 202 | - name: 'pypy39-nocov-release (ubuntu)' 203 | python: 'pypy-3.9' 204 | toxpython: 'pypy3.9' 205 | python_arch: 'x64' 206 | tox_env: 'pypy39-nocov-release' 207 | os: 'ubuntu-latest' 208 | - name: 'pypy39-nocov-debug (ubuntu)' 209 | python: 'pypy-3.9' 210 | toxpython: 'pypy3.9' 211 | python_arch: 'x64' 212 | tox_env: 'pypy39-nocov-debug' 213 | os: 'ubuntu-latest' 214 | - name: 'pypy310-cover-release (ubuntu)' 215 | python: 'pypy-3.10' 216 | toxpython: 'pypy3.10' 217 | python_arch: 'x64' 218 | tox_env: 'pypy310-cover-release' 219 | os: 'ubuntu-latest' 220 | - name: 'pypy310-cover-debug (ubuntu)' 221 | python: 'pypy-3.10' 222 | toxpython: 'pypy3.10' 223 | python_arch: 'x64' 224 | tox_env: 'pypy310-cover-debug' 225 | os: 'ubuntu-latest' 226 | - name: 'pypy310-nocov-release (ubuntu)' 227 | python: 'pypy-3.10' 228 | toxpython: 'pypy3.10' 229 | python_arch: 'x64' 230 | tox_env: 'pypy310-nocov-release' 231 | os: 'ubuntu-latest' 232 | - name: 'pypy310-nocov-debug (ubuntu)' 233 | python: 'pypy-3.10' 234 | toxpython: 'pypy3.10' 235 | python_arch: 'x64' 236 | tox_env: 'pypy310-nocov-debug' 237 | os: 'ubuntu-latest' 238 | steps: 239 | - uses: actions/checkout@v4 240 | with: 241 | fetch-depth: 0 242 | - uses: actions/setup-python@v5 243 | with: 244 | python-version: ${{ matrix.python }} 245 | architecture: ${{ matrix.python_arch }} 246 | - name: install dependencies 247 | run: | 248 | python -mpip install --progress-bar=off -r ci/requirements.txt 249 | virtualenv --version 250 | pip --version 251 | tox --version 252 | pip list --format=freeze 253 | - name: test 254 | env: 255 | TOXPYTHON: '${{ matrix.toxpython }}' 256 | run: > 257 | tox -e ${{ matrix.tox_env }} -v 258 | finish: 259 | needs: test 260 | if: ${{ always() }} 261 | runs-on: ubuntu-latest 262 | steps: 263 | - uses: coverallsapp/github-action@v2 264 | with: 265 | parallel-finished: true 266 | - uses: codecov/codecov-action@v3 267 | with: 268 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 269 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # Temp files 5 | .*.sw[po] 6 | *~ 7 | *.bak 8 | .DS_Store 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Build and package files 14 | *.egg 15 | *.egg-info 16 | .bootstrap 17 | .build 18 | .cache 19 | .eggs 20 | .env 21 | .installed.cfg 22 | .ve 23 | bin 24 | build 25 | develop-eggs 26 | dist 27 | eggs 28 | lib 29 | lib64 30 | parts 31 | pip-wheel-metadata/ 32 | pyvenv*/ 33 | sdist 34 | var 35 | venv*/ 36 | wheelhouse 37 | 38 | # Installer logs 39 | pip-log.txt 40 | 41 | # Unit test / coverage reports 42 | .benchmarks 43 | .coverage 44 | .coverage.* 45 | .pytest 46 | .pytest_cache/ 47 | .tox 48 | coverage.xml 49 | htmlcov 50 | nosetests.xml 51 | 52 | # Translations 53 | *.mo 54 | 55 | # Buildout 56 | .mr.developer.cfg 57 | 58 | # IDE project files 59 | *.iml 60 | *.komodoproject 61 | .idea 62 | .project 63 | .pydevproject 64 | .vscode 65 | 66 | # Complexity 67 | output/*.html 68 | output/*/index.html 69 | 70 | # Sphinx 71 | docs/_build 72 | 73 | # Mypy Cache 74 | .mypy_cache/ 75 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # To install the git pre-commit hooks run: 2 | # pre-commit install --install-hooks 3 | # To update the versions: 4 | # pre-commit autoupdate 5 | exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg)(/|$)' 6 | # Note the order is intentional to avoid multiple passes of the hooks 7 | repos: 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.5.7 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix, --show-fixes, --unsafe-fixes] 13 | - id: ruff-format 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v4.6.0 16 | hooks: 17 | - id: trailing-whitespace 18 | - id: end-of-file-fixer 19 | - id: debug-statements 20 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | version: 2 3 | sphinx: 4 | configuration: docs/conf.py 5 | formats: all 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3" 10 | python: 11 | install: 12 | - requirements: docs/requirements.txt 13 | - method: pip 14 | path: . 15 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * Ionel Cristian Mărieș - https://blog.ionelmc.ro 6 | * Jonas Maurus - https://github.com/jdelic 7 | * Felix Yan - https://github.com/felixonmars 8 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 2.0.0 (2022-10-20) 6 | ------------------ 7 | 8 | * Drop support for legacy Pythons (2.7, 3.6 or older). 9 | * Remove Travis/Appveyor CI and switch to GitHub Actions. 10 | * Added support for Tornado 6 (in the test suite). 11 | 12 | 1.5.2 (2020-11-15) 13 | ------------------ 14 | 15 | * Fixed broken import on Python 3.9. 16 | Contributed by Felix Yan in `#19 `_. 17 | 18 | 1.5.1 (2020-06-11) 19 | ------------------ 20 | 21 | * Remove some debug leftover prints from `v1.5.0`. 22 | 23 | 1.5.0 (2020-04-05) 24 | ------------------ 25 | 26 | * Fixed ``weave`` to stop reading attributes that don't match the method selector. 27 | Contributed by Jonas Maurus in `#14 `_. 28 | * Added support for Python 3.7 and 3.8 (``async def`` functions and generators). 29 | 30 | 1.4.2 (2016-05-10) 31 | ------------------ 32 | 33 | * Fix forgotten debug prints. 34 | 35 | 1.4.1 (2016-05-06) 36 | ------------------ 37 | 38 | * Fixed weaving of objects that don't live on root-level modules. 39 | 40 | 1.4.0 (2016-04-09) 41 | ------------------ 42 | 43 | * Corrected weaving of methods, the weaved function should be unbound. 44 | * Rolling back only applies undos once. 45 | * Added a convenience ``weave`` fixture for pytest. 46 | 47 | 1.3.3 (2015-10-02) 48 | ------------------ 49 | 50 | * Fixed typo in ``ABSOLUTELLY_ALL_METHODS`` name (now ``ABSOLUTELY_ALL_METHODS``). Old name is still there for 51 | backwards compatibility. 52 | 53 | 1.3.2 (2015-09-22) 54 | ------------------ 55 | 56 | * Fixed another tricky bug in the generator advising wrappers - result was not returned if only `Proceed` was yielded. 57 | 58 | 1.3.1 (2015-09-12) 59 | ------------------ 60 | 61 | * Corrected result handling when using Aspects on generators. 62 | 63 | 1.3.0 (2015-06-06) 64 | ------------------ 65 | 66 | * Added ``messages`` property to :obj:`aspectlib.test.LogCapture`. Change ``call`` to have level name instead of number. 67 | * Fixed a bogus warning from :func:`aspectlib.patch_module`` when patching methods on old style classes. 68 | 69 | 1.2.2 (2014-11-25) 70 | ------------------ 71 | 72 | * Added support for weakrefs in the ``__logged__`` wrapper from :obj:`aspectlib.debug.log` decorator. 73 | 74 | 1.2.1 (2014-10-15) 75 | ------------------ 76 | 77 | * Don't raise exceptions from ``Replay.__exit__`` if there would be an error (makes original cause hard to debug). 78 | 79 | 1.2.0 (2014-06-24) 80 | ------------------ 81 | 82 | * Fixed weaving methods that were defined in some baseclass (and not on the target class). 83 | * Fixed wrong qualname beeing used in the Story/Replay recording. Now used the alias given to the weaver instead of 84 | whatever is the realname on the current platform. 85 | 86 | 1.1.1 (2014-06-14) 87 | ------------------ 88 | 89 | * Use ``ASPECTLIB_DEBUG`` for every logger in ``aspectlib``. 90 | 91 | 1.1.0 (2014-06-13) 92 | ------------------ 93 | 94 | * Added a `bind` option to :obj:`aspectlib.Aspect` so you can access the cutpoint from the advisor. 95 | * Replaced automatic importing in :obj:`aspectlib.test.Replay` with extraction of context variables (locals and globals 96 | from the calling :obj:`aspectlib.test.Story`). Works better than the previous inference of module from AST of the 97 | result. 98 | * All the methods on the replay are now properties: :obj:`aspectlib.test.Story.diff`, 99 | :obj:`aspectlib.test.Story.unexpected` and :obj:`aspectlib.test.Story.missing`. 100 | * Added :obj:`aspectlib.test.Story.actual` and :obj:`aspectlib.test.Story.expected`. 101 | * Added an ``ASPECTLIB_DEBUG`` environment variable option to switch on debug logging in ``aspectlib``'s internals. 102 | 103 | 1.0.0 (2014-05-03) 104 | ------------------ 105 | 106 | * Reworked the internals :obj:`aspectlib.test.Story` to keep call ordering, to allow dependencies and improved the 107 | serialization (used in the diffs and the missing/unexpected lists). 108 | 109 | 110 | 0.9.0 (2014-04-16) 111 | ------------------ 112 | 113 | * Changed :obj:`aspectlib.test.record`: 114 | 115 | * Renamed `history` option to `calls`. 116 | * Renamed `call` option to `iscalled`. 117 | * Added `callback` option. 118 | * Added `extended` option. 119 | 120 | * Changed :obj:`aspectlib.weave`: 121 | 122 | * Allow weaving everything in a module. 123 | * Allow weaving instances of new-style classes. 124 | 125 | * Added :obj:`aspectlib.test.Story` class for capture-replay and stub/mock testing. 126 | 127 | 0.8.1 (2014-04-01) 128 | ------------------ 129 | 130 | * Use simpler import for the py3support. 131 | 132 | 0.8.0 (2014-03-31) 133 | ------------------ 134 | 135 | * Change :obj:`aspectlib.debug.log` to use :obj:`~aspectlib.Aspect` and work as expected with coroutines or generators. 136 | * Fixed :obj:`aspectlib.debug.log` to work on Python 3.4. 137 | * Remove the undocumented ``aspectlib.Yield`` advice. It was only usable when decorating generators. 138 | 139 | 0.7.0 (2014-03-28) 140 | ------------------ 141 | 142 | * Add support for decorating generators and coroutines in :obj:`~aspectlib.Aspect`. 143 | * Made aspectlib raise better exceptions. 144 | 145 | 0.6.1 (2014-03-22) 146 | ------------------ 147 | 148 | * Fix checks inside :obj:`aspectlib.debug.log` that would inadvertently call ``__bool__``/``__nonzero``. 149 | 150 | 0.6.0 (2014-03-17) 151 | ------------------ 152 | 153 | * Don't include __getattribute__ in ALL_METHODS - it's too dangerous dangerous dangerous dangerous dangerous dangerous 154 | ... ;) 155 | * Do a more reliable check for old-style classes in debug.log 156 | * When weaving a class don't weave attributes that are callable but are not actually routines (functions, methods etc) 157 | 158 | 0.5.0 (2014-03-16) 159 | ------------------ 160 | 161 | * Changed :obj:`aspectlib.debug.log`: 162 | 163 | * Renamed `arguments` to `call_args`. 164 | * Renamed `arguments_repr` to `call_args_repr`. 165 | * Added `call` option. 166 | * Fixed issue with logging from old-style methods (object name was a generic "instance"). 167 | 168 | * Fixed issues with weaving some types of builtin methods. 169 | * Allow to apply multiple aspects at the same time. 170 | * Validate string targets before weaving. ``aspectlib.weave('mod.invalid name', aspect)`` now gives a clear error 171 | (``invalid name`` is not a valid identifier) 172 | * Various documentation improvements and examples. 173 | 174 | 0.4.1 (2014-03-08) 175 | ------------------ 176 | 177 | * Remove junk from 0.4.0's source distribution. 178 | 179 | 0.4.0 (2014-03-08) 180 | ------------------ 181 | 182 | * Changed :obj:`aspectlib.weave`: 183 | 184 | * Replaced `only_methods`, `skip_methods`, `skip_magicmethods` options with `methods`. 185 | * Renamed `on_init` option to `lazy`. 186 | * Added `aliases` option. 187 | * Replaced `skip_subclasses` option with `subclasses`. 188 | 189 | * Fixed weaving methods from a string target. 190 | 191 | 0.3.1 (2014-03-05) 192 | ------------------ 193 | 194 | * `???` 195 | 196 | 0.3.0 (2014-03-05) 197 | ------------------ 198 | 199 | * First public release. 200 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | Aspectlib could always use more documentation, whether as part of the 21 | official Aspectlib docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/ionelmc/python-aspectlib/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `python-aspectlib` for local development: 39 | 40 | 1. Fork `python-aspectlib `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/python-aspectlib.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes run all the checks and docs builder with one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``). 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | Tips 77 | ---- 78 | 79 | To run a subset of tests:: 80 | 81 | tox -e envname -- pytest -k test_myfeature 82 | 83 | To run all the test environments in *parallel*:: 84 | 85 | tox -p auto 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2014-2024, Ionel Cristian Mărieș. All rights reserved. 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 5 | following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 8 | disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 11 | disclaimer in the documentation and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 14 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 15 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 16 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 17 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 18 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 19 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.cfg 7 | include .cookiecutterrc 8 | include .coveragerc 9 | include .editorconfig 10 | include .github/workflows/github-actions.yml 11 | include .pre-commit-config.yaml 12 | include .readthedocs.yml 13 | include pytest.ini 14 | include tox.ini 15 | 16 | include AUTHORS.rst 17 | include CHANGELOG.rst 18 | include CONTRIBUTING.rst 19 | include LICENSE 20 | include README.rst 21 | 22 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | * - docs 11 | - |docs| 12 | * - tests 13 | - |github-actions| |coveralls| |codecov| 14 | * - package 15 | - |version| |wheel| |supported-versions| |supported-implementations| |commits-since| 16 | .. |docs| image:: https://readthedocs.org/projects/python-aspectlib/badge/?style=flat 17 | :target: https://readthedocs.org/projects/python-aspectlib/ 18 | :alt: Documentation Status 19 | 20 | .. |github-actions| image:: https://github.com/ionelmc/python-aspectlib/actions/workflows/github-actions.yml/badge.svg 21 | :alt: GitHub Actions Build Status 22 | :target: https://github.com/ionelmc/python-aspectlib/actions 23 | 24 | .. |coveralls| image:: https://coveralls.io/repos/github/ionelmc/python-aspectlib/badge.svg?branch=main 25 | :alt: Coverage Status 26 | :target: https://coveralls.io/github/ionelmc/python-aspectlib?branch=main 27 | 28 | .. |codecov| image:: https://codecov.io/gh/ionelmc/python-aspectlib/branch/main/graphs/badge.svg?branch=main 29 | :alt: Coverage Status 30 | :target: https://app.codecov.io/github/ionelmc/python-aspectlib 31 | 32 | .. |version| image:: https://img.shields.io/pypi/v/aspectlib.svg 33 | :alt: PyPI Package latest release 34 | :target: https://pypi.org/project/aspectlib 35 | 36 | .. |wheel| image:: https://img.shields.io/pypi/wheel/aspectlib.svg 37 | :alt: PyPI Wheel 38 | :target: https://pypi.org/project/aspectlib 39 | 40 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/aspectlib.svg 41 | :alt: Supported versions 42 | :target: https://pypi.org/project/aspectlib 43 | 44 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/aspectlib.svg 45 | :alt: Supported implementations 46 | :target: https://pypi.org/project/aspectlib 47 | 48 | .. |commits-since| image:: https://img.shields.io/github/commits-since/ionelmc/python-aspectlib/v2.0.0.svg 49 | :alt: Commits since latest release 50 | :target: https://github.com/ionelmc/python-aspectlib/compare/v2.0.0...main 51 | 52 | 53 | 54 | .. end-badges 55 | 56 | ``aspectlib`` is an aspect-oriented programming, monkey-patch and decorators library. It is useful when changing 57 | behavior in existing code is desired. It includes tools for debugging and testing: simple mock/record and a complete 58 | capture/replay framework. 59 | 60 | * Free software: BSD 2-Clause License 61 | 62 | Installation 63 | ============ 64 | 65 | :: 66 | 67 | pip install aspectlib 68 | 69 | You can also install the in-development version with:: 70 | 71 | pip install https://github.com/ionelmc/python-aspectlib/archive/main.zip 72 | 73 | 74 | Documentation 75 | ============= 76 | 77 | Docs are hosted at readthedocs.org: `python-aspectlib docs `_. 78 | 79 | Implementation status 80 | ===================== 81 | 82 | Weaving functions, methods, instances and classes is completed. 83 | 84 | Pending: 85 | 86 | * *"Concerns"* (see `docs/todo.rst`) 87 | 88 | If ``aspectlib.weave`` doesn't work for your scenario please report a bug! 89 | 90 | Requirements 91 | ============ 92 | 93 | :OS: Any 94 | :Runtime: Python 2.6, 2.7, 3.3, 3.4 or PyPy 95 | 96 | Python 3.2, 3.1 and 3.0 are *NOT* supported (some objects are too crippled). 97 | 98 | Similar projects 99 | ================ 100 | 101 | * `function_trace `_ - extremely simple 102 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security contact information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import pathlib 4 | import subprocess 5 | import sys 6 | 7 | base_path: pathlib.Path = pathlib.Path(__file__).resolve().parent.parent 8 | templates_path = base_path / 'ci' / 'templates' 9 | 10 | 11 | def check_call(args): 12 | print('+', *args) 13 | subprocess.check_call(args) 14 | 15 | 16 | def exec_in_env(): 17 | env_path = base_path / '.tox' / 'bootstrap' 18 | if sys.platform == 'win32': 19 | bin_path = env_path / 'Scripts' 20 | else: 21 | bin_path = env_path / 'bin' 22 | if not env_path.exists(): 23 | import subprocess 24 | 25 | print(f'Making bootstrap env in: {env_path} ...') 26 | try: 27 | check_call([sys.executable, '-m', 'venv', env_path]) 28 | except subprocess.CalledProcessError: 29 | try: 30 | check_call([sys.executable, '-m', 'virtualenv', env_path]) 31 | except subprocess.CalledProcessError: 32 | check_call(['virtualenv', env_path]) 33 | print('Installing `jinja2` into bootstrap environment...') 34 | check_call([bin_path / 'pip', 'install', 'jinja2', 'tox']) 35 | python_executable = bin_path / 'python' 36 | if not python_executable.exists(): 37 | python_executable = python_executable.with_suffix('.exe') 38 | 39 | print(f'Re-executing with: {python_executable}') 40 | print('+ exec', python_executable, __file__, '--no-env') 41 | os.execv(python_executable, [python_executable, __file__, '--no-env']) 42 | 43 | 44 | def main(): 45 | import jinja2 46 | 47 | print(f'Project path: {base_path}') 48 | 49 | jinja = jinja2.Environment( 50 | loader=jinja2.FileSystemLoader(str(templates_path)), 51 | trim_blocks=True, 52 | lstrip_blocks=True, 53 | keep_trailing_newline=True, 54 | ) 55 | tox_environments = [ 56 | line.strip() 57 | # 'tox' need not be installed globally, but must be importable 58 | # by the Python that is running this script. 59 | # This uses sys.executable the same way that the call in 60 | # cookiecutter-pylibrary/hooks/post_gen_project.py 61 | # invokes this bootstrap.py itself. 62 | for line in subprocess.check_output([sys.executable, '-m', 'tox', '--listenvs'], universal_newlines=True).splitlines() 63 | ] 64 | tox_environments = [line for line in tox_environments if line.startswith('py')] 65 | for template in templates_path.rglob('*'): 66 | if template.is_file(): 67 | template_path = template.relative_to(templates_path).as_posix() 68 | destination = base_path / template_path 69 | destination.parent.mkdir(parents=True, exist_ok=True) 70 | destination.write_text(jinja.get_template(template_path).render(tox_environments=tox_environments)) 71 | print(f'Wrote {template_path}') 72 | print('DONE.') 73 | 74 | 75 | if __name__ == '__main__': 76 | args = sys.argv[1:] 77 | if args == ['--no-env']: 78 | main() 79 | elif not args: 80 | exec_in_env() 81 | else: 82 | print(f'Unexpected arguments: {args}', file=sys.stderr) 83 | sys.exit(1) 84 | -------------------------------------------------------------------------------- /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | virtualenv>=16.6.0 2 | pip>=19.1.1 3 | setuptools>=18.0.1 4 | six>=1.14.0 5 | tox 6 | twine 7 | -------------------------------------------------------------------------------- /ci/templates/.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: {{ '${{ matrix.name }}' }} 6 | runs-on: {{ '${{ matrix.os }}' }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.11' 14 | toxpython: 'python3.11' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | - name: 'docs' 18 | python: '3.11' 19 | toxpython: 'python3.11' 20 | tox_env: 'docs' 21 | os: 'ubuntu-latest' 22 | {% for env in tox_environments %} 23 | {% set prefix = env.split('-')[0] -%} 24 | {% if prefix.startswith('pypy') %} 25 | {% set python %}pypy-{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 26 | {% set cpython %}pp{{ prefix[4:5] }}{% endset %} 27 | {% set toxpython %}pypy{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 28 | {% else %} 29 | {% set python %}{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 30 | {% set cpython %}cp{{ prefix[2:] }}{% endset %} 31 | {% set toxpython %}python{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 32 | {% endif %} 33 | {% for os, python_arch in [ 34 | ['ubuntu', 'x64'], 35 | ] %} 36 | - name: '{{ env }} ({{ os }})' 37 | python: '{{ python }}' 38 | toxpython: '{{ toxpython }}' 39 | python_arch: '{{ python_arch }}' 40 | tox_env: '{{ env }}' 41 | os: '{{ os }}-latest' 42 | {% endfor %} 43 | {% endfor %} 44 | steps: 45 | - uses: actions/checkout@v4 46 | with: 47 | fetch-depth: 0 48 | - uses: actions/setup-python@v5 49 | with: 50 | python-version: {{ '${{ matrix.python }}' }} 51 | architecture: {{ '${{ matrix.python_arch }}' }} 52 | - name: install dependencies 53 | run: | 54 | python -mpip install --progress-bar=off -r ci/requirements.txt 55 | virtualenv --version 56 | pip --version 57 | tox --version 58 | pip list --format=freeze 59 | - name: test 60 | env: 61 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 62 | run: > 63 | tox -e {{ '${{ matrix.tox_env }}' }} -v 64 | finish: 65 | needs: test 66 | if: {{ '${{ always() }}' }} 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: coverallsapp/github-action@v2 70 | with: 71 | parallel-finished: true 72 | - uses: codecov/codecov-action@v3 73 | with: 74 | CODECOV_TOKEN: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} 75 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | extensions = [ 2 | 'sphinx.ext.autodoc', 3 | 'sphinx.ext.autosummary', 4 | 'sphinx.ext.coverage', 5 | 'sphinx.ext.doctest', 6 | 'sphinx.ext.extlinks', 7 | 'sphinx.ext.ifconfig', 8 | 'sphinx.ext.napoleon', 9 | 'sphinx.ext.todo', 10 | 'sphinx.ext.viewcode', 11 | ] 12 | source_suffix = '.rst' 13 | master_doc = 'index' 14 | project = 'Aspectlib' 15 | year = '2014-2024' 16 | author = 'Ionel Cristian Mărieș' 17 | copyright = f'{year}, {author}' 18 | version = release = '2.0.0' 19 | 20 | pygments_style = 'trac' 21 | templates_path = ['.'] 22 | extlinks = { 23 | 'issue': ('https://github.com/ionelmc/python-aspectlib/issues/%s', '#%s'), 24 | 'pr': ('https://github.com/ionelmc/python-aspectlib/pull/%s', 'PR #%s'), 25 | } 26 | 27 | html_theme = 'furo' 28 | html_theme_options = { 29 | 'githuburl': 'https://github.com/ionelmc/python-aspectlib/', 30 | } 31 | 32 | html_use_smartypants = True 33 | html_last_updated_fmt = '%b %d, %Y' 34 | html_split_index = False 35 | html_short_title = f'{project}-{version}' 36 | 37 | napoleon_use_ivar = True 38 | napoleon_use_rtype = False 39 | napoleon_use_param = False 40 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | Development is happening on `Github `_. 5 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Retrying 5 | --------------- 6 | 7 | .. code-block:: python 8 | 9 | class Client(object): 10 | def __init__(self, address): 11 | self.address = address 12 | self.connect() 13 | def connect(self): 14 | # establish connection 15 | def action(self, data): 16 | # do some stuff 17 | 18 | Now patch the ``Client`` class to have the retry functionality on all its methods: 19 | 20 | .. code-block:: python 21 | 22 | aspectlib.weave(Client, aspectlib.contrib.retry()) 23 | 24 | or with different retry options (reconnect before retry): 25 | 26 | .. code-block:: python 27 | 28 | aspectlib.weave(Client, aspectlib.contrib.retry(prepare=lambda self, *_: self.connect()) 29 | 30 | or just for one method: 31 | 32 | .. code-block:: python 33 | 34 | aspectlib.weave(Client.action, aspectlib.contrib.retry()) 35 | 36 | You can see here the advantage of having reusable retry functionality. Also, the retry handling is 37 | decoupled from the ``Client`` class. 38 | 39 | Debugging 40 | --------- 41 | 42 | ... those damn sockets: 43 | 44 | .. doctest:: pycon 45 | 46 | >>> import aspectlib, socket, sys 47 | >>> with aspectlib.weave( 48 | ... socket.socket, 49 | ... aspectlib.debug.log( 50 | ... print_to=sys.stdout, 51 | ... stacktrace=None, 52 | ... ), 53 | ... lazy=True, 54 | ... ): 55 | ... s = socket.socket() 56 | ... s.connect(('example.com', 80)) 57 | ... s.send(b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n') 58 | ... s.recv(8) 59 | ... s.close() 60 | ... 61 | {socket...}.connect(('example.com', 80)) 62 | {socket...}.connect => None 63 | {socket...}.send(...'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n') 64 | {socket...}.send => 37 65 | 37 66 | {socket...}.recv(8) 67 | {socket...}.recv => ...HTTP/1.1... 68 | ...'HTTP/1.1' 69 | ... 70 | 71 | The output looks a bit funky because it is written to be run by `doctest 72 | `_ - so you don't use broken examples :) 73 | 74 | Testing 75 | ------- 76 | 77 | Mock behavior for tests: 78 | 79 | .. code-block:: python 80 | 81 | class MyTestCase(unittest.TestCase): 82 | 83 | def test_stuff(self): 84 | 85 | @aspectlib.Aspect 86 | def mock_stuff(self, value): 87 | if value == 'special': 88 | yield aspectlib.Return('mocked-result') 89 | else: 90 | yield aspectlib.Proceed 91 | 92 | with aspectlib.weave(foo.Bar.stuff, mock_stuff): 93 | obj = foo.Bar() 94 | self.assertEqual(obj.stuff('special'), 'mocked-result') 95 | 96 | Profiling 97 | --------- 98 | 99 | There's no decorator for such in aspectlib but you can use any of the many choices on `PyPI `_. 100 | 101 | Here's one example with `profilestats`: 102 | 103 | .. code-block:: pycon 104 | 105 | >>> import os, sys, aspectlib, profilestats 106 | >>> with aspectlib.weave('os.path.join', profilestats.profile(print_stats=10, dump_stats=True)): 107 | ... print("os.path.join will be run with a profiler:") 108 | ... os.path.join('a', 'b') 109 | ... 110 | os.path.join will be run with a profiler: 111 | ... function calls in ... seconds 112 | ... 113 | Ordered by: cumulative time 114 | ... 115 | ncalls tottime percall cumtime percall filename:lineno(function) 116 | ... 0.000 0.000 0.000 0.000 ... 117 | ... 0.000 0.000 0.000 0.000 ... 118 | ... 0.000 0.000 0.000 0.000 ... 119 | ... 0.000 0.000 0.000 0.000 ... 120 | ... 121 | ... 122 | 'a...b' 123 | 124 | You can even mix it with the :obj:`aspectlib.debug.log` aspect: 125 | 126 | .. code-block:: pycon 127 | 128 | >>> import aspectlib.debug 129 | >>> with aspectlib.weave('os.path.join', [profilestats.profile(print_stats=10, dump_stats=True), aspectlib.debug.log(print_to=sys.stdout)]): 130 | ... print("os.path.join will be run with a profiler and aspectlib.debug.log:") 131 | ... os.path.join('a', 'b') 132 | ... 133 | os.path.join will be run with a profiler and aspectlib.debug.log: 134 | join('a', 'b') <<< ... 135 | ... function calls in ... seconds 136 | ... 137 | Ordered by: cumulative time 138 | ... 139 | ncalls tottime percall cumtime percall filename:lineno(function) 140 | ... 0.000 0.000 0.000 0.000 ... 141 | ... 0.000 0.000 0.000 0.000 ... 142 | ... 0.000 0.000 0.000 0.000 ... 143 | ... 0.000 0.000 0.000 0.000 ... 144 | ... 145 | ... 146 | 'a/b' 147 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | Frequently asked questions 2 | ========================== 3 | 4 | Why is it called weave and not patch ? 5 | -------------------------------------- 6 | 7 | Because it does more things that just patching. Depending on the *target* object it will patch and/or create one or more 8 | subclasses and objects. 9 | 10 | Why doesn't aspectlib implement AOP like in framework X and Y ? 11 | --------------------------------------------------------------- 12 | 13 | Some frameworks don't resort to monkey patching but instead force the user to use ridiculous amounts of abstractions and 14 | wrapping in order to make weaving possible. Notable example: `spring-python 15 | `_. 16 | 17 | For all intents and purposes I think it's wrong to have such high amount of boilerplate in Python. 18 | 19 | Also, ``aspectlib`` is targeting a different stage of development: the maintenance stage - where the code is already 20 | written and needs additional behavior, in a hurry :) 21 | 22 | Where code is written from scratch and AOP is desired there are better choices than both ``aspectlib`` and 23 | ``spring-python``. 24 | 25 | Why was aspectlib written ? 26 | --------------------------- 27 | 28 | ``aspectlib`` was initially written because I was tired of littering other people's code with prints and logging 29 | statements just to fix one bug or understand how something works. ``aspectlib.debug.log`` is ``aspectlib``'s *crown 30 | jewel*. Of course, ``aspectlib`` has other applications, see the :doc:`rationale`. 31 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contents 3 | ======== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | introduction 9 | installation 10 | Testing 11 | rationale 12 | faq 13 | examples 14 | reference/index 15 | development 16 | todo 17 | contributing 18 | changelog 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | pip install aspectlib 8 | 9 | 10 | Or, if you live in the stone age:: 11 | 12 | easy_install aspectlib 13 | 14 | For your convenience there is a `python-aspectlib `_ meta-package that will 15 | just install `aspectlib `_, in case you run ``pip install python-aspectlib`` by 16 | mistake. 17 | 18 | Requirements 19 | ------------ 20 | 21 | :OS: Any 22 | :Runtime: Python 2.6, 2.7, 3.3, 3.4 or PyPy 23 | 24 | Python 3.2, 3.1 and 3.0 are *NOT* supported (some objects are too crippled). 25 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Introduction 3 | ============ 4 | 5 | `aspectlib` provides two core tools to do `AOP `_: Aspects and 6 | a weaver. 7 | 8 | The aspect 9 | ========== 10 | 11 | An *aspect* can be created by decorating a generator with an :obj:`~aspectlib.Aspect`. The generator yields *advices* - 12 | simple behavior changing instructions. 13 | 14 | An :obj:`~aspectlib.Aspect` instance is a simple function decorator. Decorating a function with an *aspect* will change 15 | the function's behavior according to the *advices* yielded by the generator. 16 | 17 | Example: 18 | 19 | .. code-block:: python 20 | 21 | @aspectlib.Aspect 22 | def strip_return_value(*args, **kwargs): 23 | result = yield aspectlib.Proceed 24 | yield aspectlib.Return(result.strip()) 25 | 26 | @strip_return_value 27 | def read(name): 28 | return open(name).read() 29 | 30 | .. _advices: 31 | 32 | Advices 33 | ------- 34 | 35 | You can use these *advices*: 36 | 37 | * :obj:`~aspectlib.Proceed` or ``None`` - Calls the wrapped function with the default arguments. The *yield* returns 38 | the function's return value or raises an exception. Can be used multiple times (will call the function 39 | multiple times). 40 | * :obj:`~aspectlib.Proceed` ``(*args, **kwargs)`` - Same as above but with different arguments. 41 | * :obj:`~aspectlib.Return` - Makes the wrapper return ``None`` instead. If ``aspectlib.Proceed`` was never used then 42 | the wrapped function is not called. After this the generator is closed. 43 | * :obj:`~aspectlib.Return` ``(value)`` - Same as above but returns the given ``value`` instead of ``None``. 44 | * ``raise exception`` - Makes the wrapper raise an exception. 45 | 46 | The weaver 47 | ========== 48 | 49 | Patches classes and functions with the given *aspect*. When used with a class it will patch all the methods. In AOP 50 | parlance these patched functions and methods are referred to as *cut-points*. 51 | 52 | Returns a :class:`~aspectlib.Rollback` object that can be used a context manager. 53 | It will undo all the changes at the end of the context. 54 | 55 | Example: 56 | 57 | .. code-block:: python 58 | :emphasize-lines: 5 59 | 60 | @aspectlib.Aspect 61 | def mock_open(): 62 | yield aspectlib.Return(StringIO("mystuff")) 63 | 64 | with aspectlib.weave(open, mock_open): 65 | assert open("/doesnt/exist.txt").read() == "mystuff" 66 | 67 | You can use :func:`aspectlib.weave` on: classes, instances, builtin functions, module level functions, methods, 68 | classmethods, staticmethods, instance methods etc. 69 | -------------------------------------------------------------------------------- /docs/presentations/pycon.se-lightningtalk.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-aspectlib/52092d5e8ee86705c0cad9f9b510dcedabbdc46f/docs/presentations/pycon.se-lightningtalk.pdf -------------------------------------------------------------------------------- /docs/presentations/pycon.se-lightningtalk.rst: -------------------------------------------------------------------------------- 1 | .. raw:: html 2 | 3 | 7 | 8 | Does this look familiar ? 9 | ========================= 10 | 11 | .. sourcecode:: pycon 12 | 13 | >>> from functools import wraps 14 | >>> def log_errors(func): 15 | ... @wraps(func) 16 | ... def log_errors_wrapper(*args, **kwargs): 17 | ... try: 18 | ... return func(*args, **kwargs) 19 | ... except Exception as exc: 20 | ... print("Raised %r for %s/%s" % (exc, args, kwargs)) 21 | ... raise 22 | ... return log_errors_wrapper 23 | 24 | .. sourcecode:: pycon 25 | 26 | >>> @log_errors 27 | ... def broken_function(): 28 | ... raise RuntimeError() 29 | >>> from pytest import raises 30 | >>> t = raises(RuntimeError, broken_function) 31 | Raised RuntimeError() for ()/{} 32 | 33 | Not very nice 34 | ============= 35 | 36 | * Boiler plate 37 | * What if you use it on a generator ? 38 | 39 | What if you use it on a generator ? 40 | =================================== 41 | 42 | .. sourcecode:: pycon 43 | 44 | >>> @log_errors 45 | ... def broken_generator(): 46 | ... yield 1 47 | ... raise RuntimeError() 48 | 49 | >>> t = raises(RuntimeError, lambda: list(broken_generator())) 50 | 51 | Dooh! No output. 52 | 53 | How to fix it ? 54 | =============== 55 | 56 | .. sourcecode:: pycon 57 | 58 | >>> from inspect import isgeneratorfunction 59 | >>> def log_errors(func): 60 | ... if isgeneratorfunction(func): # because you can't both return and yield in the same function 61 | ... @wraps(func) 62 | ... def log_errors_wrapper(*args, **kwargs): 63 | ... try: 64 | ... for item in func(*args, **kwargs): 65 | ... yield item 66 | ... except Exception as exc: 67 | ... print("Raised %r for %s/%s" % (exc, args, kwargs)) 68 | ... raise 69 | ... else: 70 | ... @wraps(func) 71 | ... def log_errors_wrapper(*args, **kwargs): 72 | ... try: 73 | ... return func(*args, **kwargs) 74 | ... except Exception as exc: 75 | ... print("Raised %r for %s/%s" % (exc, args, kwargs)) 76 | ... raise 77 | ... return log_errors_wrapper 78 | 79 | Now it works: 80 | 81 | .. sourcecode:: pycon 82 | 83 | >>> @log_errors 84 | ... def broken_generator(): 85 | ... yield 1 86 | ... raise RuntimeError() 87 | 88 | >>> t = raises(RuntimeError, list, broken_generator()) 89 | Raised RuntimeError() for ()/{} 90 | 91 | **Note:** Doesn't actually work for coroutines ... it would involve more code to handle edge cases. 92 | 93 | The alternative, use ``aspectlib`` 94 | ================================== 95 | 96 | .. sourcecode:: pycon 97 | 98 | >>> from aspectlib import Aspect 99 | >>> @Aspect 100 | ... def log_errors(*args, **kwargs): 101 | ... try: 102 | ... yield 103 | ... except Exception as exc: 104 | ... print("Raised %r for %s/%s" % (exc, args, kwargs)) 105 | ... raise 106 | 107 | Works as expected with generators: 108 | 109 | .. sourcecode:: pycon 110 | 111 | >>> @log_errors 112 | ... def broken_generator(): 113 | ... yield 1 114 | ... raise RuntimeError() 115 | >>> t = raises(RuntimeError, lambda: list(broken_generator())) 116 | Raised RuntimeError() for ()/{} 117 | 118 | >>> @log_errors 119 | ... def broken_function(): 120 | ... raise RuntimeError() 121 | >>> t = raises(RuntimeError, broken_function) 122 | Raised RuntimeError() for ()/{} 123 | 124 | ``aspectlib`` 125 | ============= 126 | 127 | * **This presentation**: 128 | 129 | https://github.com/ionelmc/python-aspectlib/tree/master/docs/presentations 130 | 131 | * ``aspectlib`` **does many more things, check it out**: 132 | 133 | http://python-aspectlib.readthedocs.org/en/latest/ 134 | -------------------------------------------------------------------------------- /docs/rationale.rst: -------------------------------------------------------------------------------- 1 | Rationale 2 | ========= 3 | 4 | There are perfectly sane use cases for monkey-patching (aka *weaving*): 5 | 6 | * Instrumenting existing code for debugging, profiling and other measurements. 7 | * Testing less flexible code. In some situations it's infeasible to use dependency injection to make your code more 8 | testable. 9 | 10 | Then in those situations: 11 | 12 | * You would need to handle yourself all different kinds of patching (patching 13 | a module is different than patching a class, a function or a method for that matter). 14 | ``aspectlib`` will handle all this gross patching mumbo-jumbo for you, consistently, over many Python versions. 15 | * Writing the actual wrappers is repetitive, boring and error-prone. You can't reuse wrappers 16 | but *you can reuse function decorators*. 17 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/reference/aspectlib.contrib.rst: -------------------------------------------------------------------------------- 1 | Reference: ``aspectlib.debug`` 2 | ============================== 3 | 4 | .. autosummary:: 5 | :nosignatures: 6 | 7 | aspectlib.contrib.retry 8 | aspectlib.contrib.retry.exponential_backoff 9 | aspectlib.contrib.retry.straight_backoff 10 | aspectlib.contrib.retry.flat_backoff 11 | 12 | .. automodule:: aspectlib.contrib 13 | :members: 14 | -------------------------------------------------------------------------------- /docs/reference/aspectlib.debug.rst: -------------------------------------------------------------------------------- 1 | Reference: ``aspectlib.debug`` 2 | ============================== 3 | 4 | .. testsetup:: 5 | 6 | from aspectlib.debug import log 7 | from aspectlib import weave 8 | 9 | .. autosummary:: 10 | :nosignatures: 11 | 12 | aspectlib.debug.log 13 | aspectlib.debug.format_stack 14 | aspectlib.debug.frame_iterator 15 | aspectlib.debug.strip_non_ascii 16 | 17 | .. automodule:: aspectlib.debug 18 | :members: 19 | -------------------------------------------------------------------------------- /docs/reference/aspectlib.rst: -------------------------------------------------------------------------------- 1 | aspectlib 2 | ========= 3 | 4 | Overview 5 | -------- 6 | 7 | .. highlights:: 8 | 9 | Safe toolkit for writing decorators (hereby called **aspects**) 10 | 11 | .. autosummary:: 12 | :nosignatures: 13 | 14 | aspectlib.Aspect 15 | aspectlib.Proceed 16 | aspectlib.Return 17 | 18 | .. highlights:: 19 | 20 | Power tools for patching functions (hereby glorified as **weaving**) 21 | 22 | .. autosummary:: 23 | :nosignatures: 24 | 25 | aspectlib.ALL_METHODS 26 | aspectlib.NORMAL_METHODS 27 | aspectlib.weave 28 | aspectlib.Rollback 29 | 30 | Reference 31 | --------- 32 | 33 | .. automodule:: aspectlib 34 | :members: 35 | :exclude-members: weave, Aspect 36 | 37 | .. autoclass:: Aspect 38 | 39 | You can use these *advices*: 40 | 41 | * :obj:`~aspectlib.Proceed` or ``None`` - Calls the wrapped function with the default arguments. The *yield* returns 42 | the function's return value or raises an exception. Can be used multiple times (will call the function 43 | multiple times). 44 | * :obj:`~aspectlib.Proceed` ``(*args, **kwargs)`` - Same as above but with different arguments. 45 | * :obj:`~aspectlib.Return` - Makes the wrapper return ``None`` instead. If ``aspectlib.Proceed`` was never used then 46 | the wrapped function is not called. After this the generator is closed. 47 | * :obj:`~aspectlib.Return` ``(value)`` - Same as above but returns the given ``value`` instead of ``None``. 48 | * ``raise exception`` - Makes the wrapper raise an exception. 49 | 50 | .. note:: 51 | 52 | The Aspect will correctly handle generators and coroutines (consume them, capture result). 53 | 54 | Example:: 55 | 56 | >>> from aspectlib import Aspect 57 | >>> @Aspect 58 | ... def log_errors(*args, **kwargs): 59 | ... try: 60 | ... yield 61 | ... except Exception as exc: 62 | ... print("Raised %r for %s/%s" % (exc, args, kwargs)) 63 | ... raise 64 | 65 | Will work as expected with generators (and coroutines): 66 | 67 | 68 | .. sourcecode:: pycon 69 | 70 | >>> @log_errors 71 | ... def broken_generator(): 72 | ... yield 1 73 | ... raise RuntimeError() 74 | >>> from pytest import raises 75 | >>> raises(RuntimeError, lambda: list(broken_generator())) 76 | Raised RuntimeError() for ()/{} 77 | ... 78 | 79 | >>> @log_errors 80 | ... def broken_function(): 81 | ... raise RuntimeError() 82 | >>> raises(RuntimeError, broken_function) 83 | Raised RuntimeError() for ()/{} 84 | ... 85 | 86 | And it will handle results:: 87 | 88 | >>> from aspectlib import Aspect 89 | >>> @Aspect 90 | ... def log_results(*args, **kwargs): 91 | ... try: 92 | ... value = yield 93 | ... except Exception as exc: 94 | ... print("Raised %r for %s/%s" % (exc, args, kwargs)) 95 | ... raise 96 | ... else: 97 | ... print("Returned %r for %s/%s" % (value, args, kwargs)) 98 | 99 | >>> @log_results 100 | ... def weird_function(): 101 | ... yield 1 102 | ... raise StopIteration('foobar') # in Python 3 it's the same as: return 'foobar' 103 | >>> list(weird_function()) 104 | Returned 'foobar' for ()/{} 105 | [1] 106 | 107 | 108 | .. autoclass:: Rollback 109 | 110 | .. automethod:: __enter__ 111 | 112 | Returns self. 113 | 114 | .. automethod:: __exit__ 115 | 116 | Performs the rollback. 117 | 118 | .. automethod:: rollback 119 | 120 | Alias of ``__exit__``. 121 | 122 | .. automethod:: __call__ 123 | 124 | Alias of ``__exit__``. 125 | 126 | .. autodata:: ALL_METHODS 127 | :annotation: Weave all magic methods. Can be used as the value for methods argument in weave. 128 | 129 | .. autodata:: NORMAL_METHODS 130 | :annotation: Only weave non-magic methods. Can be used as the value for methods argument in weave. 131 | 132 | .. autofunction:: weave(target, aspect[, subclasses=True, methods=NORMAL_METHODS, lazy=False, aliases=True]) 133 | -------------------------------------------------------------------------------- /docs/reference/aspectlib.test.rst: -------------------------------------------------------------------------------- 1 | Reference: ``aspectlib.test`` 2 | ============================= 3 | 4 | This module aims to be a lightweight and flexible alternative to the popular `mock `_ 5 | framework and more. 6 | 7 | .. testsetup:: 8 | 9 | from aspectlib.test import record, mock, Story 10 | from aspectlib import weave 11 | 12 | .. autosummary:: 13 | :nosignatures: 14 | 15 | aspectlib.test.record 16 | aspectlib.test.mock 17 | aspectlib.test.Story 18 | aspectlib.test.Replay 19 | 20 | .. automodule:: aspectlib.test 21 | :members: record, mock, Story 22 | 23 | .. autoclass:: Replay(?) 24 | :members: 25 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. toctree:: 5 | 6 | aspectlib 7 | aspectlib.contrib 8 | aspectlib.debug 9 | aspectlib.test 10 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.3 2 | furo 3 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | aspectlib 2 | builtin 3 | builtins 4 | classmethod 5 | staticmethod 6 | classmethods 7 | staticmethods 8 | mumbo 9 | args 10 | kwargs 11 | callstack 12 | ascii 13 | framelist 14 | loglevel 15 | Changelog 16 | Indices 17 | nonascii 18 | cutpoint 19 | reraised 20 | py 21 | diffs 22 | filesystem 23 | Dooh 24 | cutpoints 25 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | Testing with ``aspectlib.test`` 3 | =============================== 4 | 5 | Spy & mock toolkit: ``record``/``mock`` decorators 6 | ================================================== 7 | 8 | .. highlights:: 9 | 10 | Lightweight spies and mock responses 11 | 12 | 13 | Example usage, suppose you want to test this class: 14 | 15 | >>> class ProductionClass(object): 16 | ... def method(self): 17 | ... return 'stuff' 18 | >>> real = ProductionClass() 19 | 20 | With :obj:`aspectlib.test.mock` and :obj:`aspectlib.test.record`:: 21 | 22 | >>> from aspectlib import weave, test 23 | >>> patch = weave(real.method, [test.mock(3), test.record]) 24 | >>> real.method(3, 4, 5, key='value') 25 | 3 26 | >>> assert real.method.calls == [(real, (3, 4, 5), {'key': 'value'})] 27 | 28 | As a bonus, you have an easy way to rollback all the mess:: 29 | 30 | >>> patch.rollback() 31 | >>> real.method() 32 | 'stuff' 33 | 34 | With ``mock``:: 35 | 36 | >>> from mock import Mock 37 | >>> real = ProductionClass() 38 | >>> real.method = Mock(return_value=3) 39 | >>> real.method(3, 4, 5, key='value') 40 | 3 41 | >>> real.method.assert_called_with(3, 4, 5, key='value') 42 | 43 | Capture-replay toolkit: ``Story`` and ``Replay`` 44 | ================================================ 45 | 46 | .. highlights:: 47 | 48 | Elaborate tools for testing difficult code 49 | 50 | Writing tests using the :obj:`Story ` is viable when neither `integration tests 51 | `_ or `unit tests `_ seem 52 | adequate: 53 | 54 | * Integration tests are too difficult to integrate in your test harness due to automation issues, permissions or 55 | plain lack of performance. 56 | * Unit tests are too difficult to write due to design issues (like too many dependencies, dependency injects is too 57 | hard etc) or take too much time to write to get good code coverage. 58 | 59 | The :obj:`Story ` is the middle-ground, bringing those two types of testing closer. It allows you 60 | to start with `integration tests` and later `mock`/`stub` with great ease all the dependencies. 61 | 62 | .. warning:: 63 | 64 | The :obj:`Story ` is not intended to patch and mock complex libraries that keep state around. 65 | E.g.: `requests `_ keeps a connection pool around - there are `better 66 | `_ `choices `_. 67 | 68 | .. note:: 69 | 70 | Using the :obj:`Story ` on imperative, stateless interfaces is best. 71 | 72 | An example: mocking out an external system 73 | ------------------------------------------ 74 | 75 | Suppose we implement this simple GNU ``tree`` clone:: 76 | 77 | >>> import os 78 | >>> def tree(root, prefix=''): 79 | ... if not prefix: 80 | ... print("%s%s" % (prefix, os.path.basename(root))) 81 | ... for pos, name in reversed(list(enumerate(sorted(os.listdir(root), reverse=True)))): 82 | ... print("%s%s%s" % (prefix, "|-- " if pos else "\-- ", name)) 83 | ... absname = os.path.join(root, name) 84 | ... if os.path.isdir(absname): 85 | ... tree(absname, prefix + ("| " if pos else " ")) 86 | 87 | Lets suppose we would make up some directories and files for our tests:: 88 | 89 | >>> if not os.path.exists('some/test/dir'): os.makedirs('some/test/dir') 90 | >>> if not os.path.exists('some/test/empty'): os.makedirs('some/test/empty') 91 | >>> with open('some/test/dir/file.txt', 'w') as fh: 92 | ... pass 93 | 94 | And we'll assert that ``tree`` has this output:: 95 | 96 | >>> tree('some') 97 | some 98 | \-- test 99 | |-- dir 100 | | \-- file.txt 101 | \-- empty 102 | 103 | But now we're left with some garbage and have to clean it up:: 104 | 105 | >>> import shutil 106 | >>> shutil.rmtree('some') 107 | 108 | This is not very practical - we'll need to create many scenarios, and some are not easy to create automatically (e.g: 109 | tests for permissions issues - not easy to change permissions from within a test). 110 | 111 | Normally, to handle this we'd have have to manually monkey-patch the ``os`` module with various mocks or add 112 | dependency-injection in the ``tree`` function and inject mocks. Either approach we'll leave us with very ugly code. 113 | 114 | With dependency-injection tree would look like this:: 115 | 116 | def tree(root, prefix='', basename=os.path.basename, listdir=os.listdir, join=os.path.join, isdir=os.path.isdir): 117 | ... 118 | 119 | One could argue that this is overly explicit, and the function's design is damaged by testing concerns. What if we need 120 | to check for permissions ? We'd have to extend the signature. And what if we forget to do that ? In some situations one 121 | cannot afford all this (re-)engineering (e.g: legacy code, simplicity goals etc). 122 | 123 | The :obj:`aspectlib.test.Story` is designed to solve this problem in a neat way. 124 | 125 | We can start with some existing test data in the filesystem:: 126 | 127 | >>> os.makedirs('some/test/dir') 128 | >>> os.makedirs('some/test/empty') 129 | >>> with open('some/test/dir/file.txt', 'w') as fh: 130 | ... pass 131 | 132 | Write an empty story and examine the output:: 133 | 134 | >>> from aspectlib.test import Story 135 | >>> with Story(['os.path.isdir', 'os.listdir']) as story: 136 | ... pass 137 | >>> with story.replay(strict=False) as replay: 138 | ... tree('some') 139 | some 140 | \-- test 141 | |-- dir 142 | | \-- file.txt 143 | \-- empty 144 | STORY/REPLAY DIFF: 145 | --- expected... 146 | +++ actual... 147 | @@ ... @@ 148 | +os.listdir('some') == ['test'] # returns 149 | +...isdir('some...test') == True # returns 150 | +os.listdir('some...test') == [...'empty'...] # returns 151 | +...isdir('some...test...dir') == True # returns 152 | +os.listdir('some...test...dir') == ['file.txt'] # returns 153 | +...isdir('some...test...dir...file.txt') == False # returns 154 | +...isdir('some...test...empty') == True # returns 155 | +os.listdir('some...test...empty') == [] # returns 156 | ACTUAL: 157 | os.listdir('some') == ['test'] # returns 158 | ...isdir('some...test') == True # returns 159 | os.listdir('some...test') == [...'empty'...] # returns 160 | ...isdir('some...test...dir') == True # returns 161 | os.listdir('some...test...dir') == ['file.txt'] # returns 162 | ...isdir('some...test...dir...file.txt') == False # returns 163 | ...isdir('some...test...empty') == True # returns 164 | os.listdir('some...test...empty') == [] # returns 165 | 166 | 167 | .. 168 | 169 | Now we can remove the test directories and fill the story:: 170 | 171 | >>> import shutil 172 | >>> shutil.rmtree('some') 173 | 174 | .. 175 | 176 | The story:: 177 | 178 | >>> with Story(['os.path.isdir', 'os.listdir']) as story: 179 | ... os.listdir('some') == ['test'] # returns 180 | ... os.path.isdir(os.path.join('some', 'test')) == True 181 | ... os.listdir(os.path.join('some', 'test')) == ['dir', 'empty'] 182 | ... os.path.isdir(os.path.join('some', 'test', 'dir')) == True 183 | ... os.listdir(os.path.join('some', 'test', 'dir')) == ['file.txt'] 184 | ... os.path.isdir(os.path.join('some', 'test', 'dir', 'file.txt')) == False 185 | ... os.path.isdir(os.path.join('some', 'test', 'empty')) == True 186 | ... os.listdir(os.path.join('some', 'test', 'empty')) == [] 187 | 188 | We can also disable proxying in :obj:`replay ` so that the tested code can't use the 189 | real functions:: 190 | 191 | >>> with story.replay(proxy=False) as replay: 192 | ... tree('some') 193 | some 194 | \-- test 195 | |-- dir 196 | | \-- file.txt 197 | \-- empty 198 | 199 | >>> with story.replay(proxy=False, strict=False) as replay: 200 | ... tree('missing-from-story') 201 | Traceback (most recent call last): 202 | ... 203 | AssertionError: Unexpected call to None/os.listdir with args:'missing-from-story' kwargs: 204 | -------------------------------------------------------------------------------- /docs/todo.rst: -------------------------------------------------------------------------------- 1 | TODO & Ideas 2 | ============ 3 | 4 | Validation 5 | ---------- 6 | 7 | .. code-block:: python 8 | :emphasize-lines: 24 9 | 10 | class BaseProcessor(object): 11 | def process_foo(self, data): 12 | # do some work 13 | 14 | def process_bar(self, data): 15 | # do some work 16 | 17 | class ValidationConcern(aspectlib.Concern): 18 | @aspectlib.Aspect 19 | def process_foo(self, data): 20 | # validate data 21 | if is_valid_foo(data): 22 | yield aspectlib.Proceed 23 | else: 24 | raise ValidationError() 25 | 26 | @aspectlib.Aspect 27 | def process_bar(self, data): 28 | # validate data 29 | if is_valid_bar(data): 30 | yield aspectlib.Proceed 31 | else: 32 | raise ValidationError() 33 | 34 | aspectlib.weave(BaseProcesor, ValidationConcern) 35 | 36 | class MyProcessor(BaseProcessor): 37 | def process_foo(self, data): 38 | # do some work 39 | 40 | def process_bar(self, data): 41 | # do some work 42 | 43 | # MyProcessor automatically inherits BaseProcesor's ValidationConcern 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=30.3.0", 4 | ] 5 | 6 | [tool.ruff] 7 | extend-exclude = ["static", "ci/templates"] 8 | line-length = 140 9 | src = ["src", "tests"] 10 | target-version = "py38" 11 | 12 | [tool.ruff.lint.per-file-ignores] 13 | "ci/*" = ["S"] 14 | 15 | [tool.ruff.lint] 16 | ignore = [ 17 | "RUF001", # ruff-specific rules ambiguous-unicode-character-string 18 | "S101", # flake8-bandit assert 19 | "S308", # flake8-bandit suspicious-mark-safe-usage 20 | "S603", # flake8-bandit subprocess-without-shell-equals-true 21 | "S607", # flake8-bandit start-process-with-partial-path 22 | "E501", # pycodestyle line-too-long 23 | ] 24 | select = [ 25 | "B", # flake8-bugbear 26 | "C4", # flake8-comprehensions 27 | "DTZ", # flake8-datetimez 28 | "E", # pycodestyle errors 29 | "EXE", # flake8-executable 30 | "F", # pyflakes 31 | "I", # isort 32 | "INT", # flake8-gettext 33 | "PIE", # flake8-pie 34 | "PLC", # pylint convention 35 | "PLE", # pylint errors 36 | "PT", # flake8-pytest-style 37 | "PTH", # flake8-use-pathlib 38 | "RSE", # flake8-raise 39 | "RUF", # ruff-specific rules 40 | "S", # flake8-bandit 41 | "UP", # pyupgrade 42 | "W", # pycodestyle warnings 43 | ] 44 | 45 | [tool.ruff.lint.flake8-pytest-style] 46 | fixture-parentheses = false 47 | mark-parentheses = false 48 | 49 | [tool.ruff.lint.isort] 50 | forced-separate = ["conftest"] 51 | force-single-line = true 52 | 53 | [tool.ruff.format] 54 | quote-style = "single" 55 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # If a pytest section is found in one of the possible config files 3 | # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, 4 | # so if you add a pytest config section elsewhere, 5 | # you will need to delete this section from setup.cfg. 6 | norecursedirs = 7 | .git 8 | .tox 9 | .env 10 | dist 11 | build 12 | migrations 13 | 14 | python_files = 15 | test_*.py 16 | *_test.py 17 | tests.py 18 | addopts = 19 | -ra 20 | --strict-markers 21 | --ignore=docs/conf.py 22 | --ignore=setup.py 23 | --ignore=ci 24 | --ignore=.eggs 25 | --doctest-modules 26 | --doctest-glob=\*.rst 27 | --tb=short 28 | testpaths = 29 | tests 30 | 31 | # Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors 32 | filterwarnings = 33 | error 34 | ignore:Setting test_aspectlib.MissingGlobal to . There was no previous definition, probably patching the wrong module. 35 | ignore:the \(type, exc, tb\) signature of throw\(\) is deprecated, use the single-arg signature instead.:DeprecationWarning 36 | # You can add exclusions, some examples: 37 | # ignore:'aspectlib' defines default_app_config:PendingDeprecationWarning:: 38 | # ignore:The {{% if::: 39 | # ignore:Coverage disabled via --no-cov switch! 40 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | from pathlib import Path 4 | 5 | from setuptools import find_packages 6 | from setuptools import setup 7 | 8 | 9 | def read(*names, **kwargs): 10 | with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get('encoding', 'utf8')) as fh: 11 | return fh.read() 12 | 13 | 14 | setup( 15 | name='aspectlib', 16 | version='2.0.0', 17 | license='BSD-2-Clause', 18 | description='``aspectlib`` is an aspect-oriented programming, monkey-patch and decorators library. It is useful when changing', 19 | long_description='{}\n{}'.format( 20 | re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')), 21 | re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')), 22 | ), 23 | author='Ionel Cristian Mărieș', 24 | author_email='contact@ionelmc.ro', 25 | url='https://github.com/ionelmc/python-aspectlib', 26 | packages=find_packages('src'), 27 | package_dir={'': 'src'}, 28 | py_modules=[path.stem for path in Path('src').glob('*.py')], 29 | include_package_data=True, 30 | zip_safe=False, 31 | classifiers=[ 32 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 33 | 'Development Status :: 5 - Production/Stable', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: BSD License', 36 | 'Operating System :: Unix', 37 | 'Operating System :: POSIX', 38 | 'Operating System :: Microsoft :: Windows', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3 :: Only', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Programming Language :: Python :: 3.10', 45 | 'Programming Language :: Python :: 3.11', 46 | 'Programming Language :: Python :: 3.12', 47 | 'Programming Language :: Python :: Implementation :: CPython', 48 | 'Programming Language :: Python :: Implementation :: PyPy', 49 | # uncomment if you test on these interpreters: 50 | # 'Programming Language :: Python :: Implementation :: IronPython', 51 | # 'Programming Language :: Python :: Implementation :: Jython', 52 | # 'Programming Language :: Python :: Implementation :: Stackless', 53 | 'Topic :: Utilities', 54 | ], 55 | project_urls={ 56 | 'Documentation': 'https://python-aspectlib.readthedocs.io/', 57 | 'Changelog': 'https://python-aspectlib.readthedocs.io/en/latest/changelog.html', 58 | 'Issue Tracker': 'https://github.com/ionelmc/python-aspectlib/issues', 59 | }, 60 | keywords=[ 61 | 'aop', 62 | 'aspects', 63 | 'aspect oriented programming', 64 | 'decorators', 65 | 'patch', 66 | 'monkeypatch', 67 | 'weave', 68 | 'debug', 69 | 'log', 70 | 'tests', 71 | 'mock', 72 | 'capture', 73 | 'replay', 74 | 'capture-replay', 75 | 'debugging', 76 | 'patching', 77 | 'monkeypatching', 78 | 'record', 79 | 'recording', 80 | 'mocking', 81 | 'logger', 82 | ], 83 | python_requires='>=3.8', 84 | install_requires=[ 85 | # eg: "aspectlib==1.1.1", "six>=1.7", 86 | ], 87 | extras_require={ 88 | # eg: 89 | # "rst": ["docutils>=0.11"], 90 | # ":python_version=='3.8'": ["backports.zoneinfo"], 91 | }, 92 | entry_points={ 93 | 'pytest11': ['aspectlib = aspectlib.pytestsupport'], 94 | }, 95 | ) 96 | -------------------------------------------------------------------------------- /src/aspectlib/__init__.py: -------------------------------------------------------------------------------- 1 | import builtins 2 | import re 3 | import sys 4 | import warnings 5 | from collections import deque 6 | from functools import partial 7 | from inspect import isclass 8 | from inspect import isfunction 9 | from inspect import isgenerator 10 | from inspect import isgeneratorfunction 11 | from inspect import ismethod 12 | from inspect import ismethoddescriptor 13 | from inspect import ismodule 14 | from inspect import isroutine 15 | from logging import getLogger 16 | 17 | from .utils import PY3 18 | from .utils import Sentinel 19 | from .utils import basestring 20 | from .utils import force_bind 21 | from .utils import logf 22 | from .utils import make_method_matcher 23 | from .utils import mimic 24 | 25 | try: 26 | from types import InstanceType 27 | except ImportError: 28 | InstanceType = None 29 | 30 | 31 | try: 32 | from types import ClassType 33 | except ImportError: 34 | ClassType = type 35 | 36 | try: 37 | from inspect import isasyncgenfunction 38 | except ImportError: 39 | isasyncgenfunction = None 40 | 41 | try: 42 | from inspect import iscoroutinefunction 43 | 44 | def isasyncfunction(obj): 45 | if isasyncgenfunction is None: 46 | return iscoroutinefunction(obj) 47 | else: 48 | return isasyncgenfunction(obj) or iscoroutinefunction(obj) 49 | 50 | except ImportError: 51 | isasyncfunction = None 52 | 53 | __all__ = 'weave', 'Aspect', 'Proceed', 'Return', 'ALL_METHODS', 'NORMAL_METHODS', 'ABSOLUTELY_ALL_METHODS' 54 | __version__ = '2.0.0' 55 | 56 | logger = getLogger(__name__) 57 | logdebug = logf(logger.debug) 58 | logexception = logf(logger.exception) 59 | 60 | UNSPECIFIED = Sentinel('UNSPECIFIED') 61 | ABSOLUTELLY_ALL_METHODS = re.compile('.*') 62 | ABSOLUTELY_ALL_METHODS = ABSOLUTELLY_ALL_METHODS 63 | ALL_METHODS = re.compile('(?!__getattribute__$)') 64 | NORMAL_METHODS = re.compile('(?!__.*__$)') 65 | VALID_IDENTIFIER = re.compile(r'^[^\W\d]\w*$', re.UNICODE if PY3 else 0) 66 | 67 | 68 | class UnacceptableAdvice(RuntimeError): 69 | pass 70 | 71 | 72 | class ExpectedGenerator(TypeError): 73 | pass 74 | 75 | 76 | class ExpectedGeneratorFunction(ExpectedGenerator): 77 | pass 78 | 79 | 80 | class ExpectedAdvice(TypeError): 81 | pass 82 | 83 | 84 | class UnsupportedType(TypeError): 85 | pass 86 | 87 | 88 | class Proceed: 89 | """ 90 | Instruction for calling the decorated function. Can be used multiple times. 91 | 92 | If not used as an instance then the default args and kwargs are used. 93 | """ 94 | 95 | __slots__ = 'args', 'kwargs' 96 | 97 | def __init__(self, *args, **kwargs): 98 | self.args = args 99 | self.kwargs = kwargs 100 | 101 | 102 | class Return: 103 | """ 104 | Instruction for returning a *optional* value. 105 | 106 | If not used as an instance then ``None`` is returned. 107 | """ 108 | 109 | __slots__ = ('value',) 110 | 111 | def __init__(self, value): 112 | self.value = value 113 | 114 | 115 | class Aspect: 116 | """ 117 | Container for the advice yielding generator. Can be used as a decorator on other function to change behavior 118 | according to the advices yielded from the generator. 119 | 120 | Args: 121 | advising_function (generator function): A generator function that yields :ref:`advices`. 122 | bind (bool): A convenience flag so you can access the cutpoint function (you'll get it as an argument). 123 | 124 | Usage:: 125 | 126 | >>> @Aspect 127 | ... def my_decorator(*args, **kwargs): 128 | ... print("Got called with args: %s kwargs: %s" % (args, kwargs)) 129 | ... result = yield 130 | ... print(" ... and the result is: %s" % (result,)) 131 | >>> @my_decorator 132 | ... def foo(a, b, c=1): 133 | ... print((a, b, c)) 134 | >>> foo(1, 2, c=3) 135 | Got called with args: (1, 2) kwargs: {'c': 3} 136 | (1, 2, 3) 137 | ... and the result is: None 138 | 139 | Normally you don't have access to the cutpoints (the functions you're going to use the aspect/decorator on) because 140 | you don't and should not call them directly. There are situations where you'd want to get the name or other data 141 | from the function. This is where you use the ``bind=True`` option:: 142 | 143 | >>> @Aspect(bind=True) 144 | ... def my_decorator(cutpoint, *args, **kwargs): 145 | ... print("`%s` got called with args: %s kwargs: %s" % (cutpoint.__name__, args, kwargs)) 146 | ... result = yield 147 | ... print(" ... and the result is: %s" % (result,)) 148 | >>> @my_decorator 149 | ... def foo(a, b, c=1): 150 | ... print((a, b, c)) 151 | >>> foo(1, 2, c=3) 152 | `foo` got called with args: (1, 2) kwargs: {'c': 3} 153 | (1, 2, 3) 154 | ... and the result is: None 155 | 156 | """ 157 | 158 | __slots__ = 'advising_function', 'bind' 159 | 160 | def __new__(cls, advising_function=UNSPECIFIED, bind=False): 161 | if advising_function is UNSPECIFIED: 162 | return partial(cls, bind=bind) 163 | else: 164 | self = super().__new__(cls) 165 | self.__init__(advising_function, bind) 166 | return self 167 | 168 | def __init__(self, advising_function, bind=False): 169 | if not isgeneratorfunction(advising_function): 170 | raise ExpectedGeneratorFunction(f'advising_function {advising_function} must be a generator function.') 171 | self.advising_function = advising_function 172 | self.bind = bind 173 | 174 | def __call__(self, cutpoint_function): 175 | if isasyncfunction is not None and isasyncfunction(cutpoint_function): 176 | assert isasyncgenfunction(cutpoint_function) or iscoroutinefunction(cutpoint_function) 177 | 178 | async def advising_asyncgenerator_wrapper_py35(*args, **kwargs): 179 | if self.bind: 180 | advisor = self.advising_function(cutpoint_function, *args, **kwargs) 181 | else: 182 | advisor = self.advising_function(*args, **kwargs) 183 | if not isgenerator(advisor): 184 | raise ExpectedGenerator(f'advising_function {self.advising_function} did not return a generator.') 185 | try: 186 | advice = next(advisor) 187 | while True: 188 | logdebug('Got advice %r from %s', advice, self.advising_function) 189 | if advice is Proceed or advice is None or isinstance(advice, Proceed): 190 | if isinstance(advice, Proceed): 191 | args = advice.args 192 | kwargs = advice.kwargs 193 | gen = cutpoint_function(*args, **kwargs) 194 | try: 195 | result = await gen 196 | except BaseException: 197 | advice = advisor.throw(*sys.exc_info()) 198 | else: 199 | try: 200 | advice = advisor.send(result) 201 | except StopIteration: 202 | return result 203 | finally: 204 | gen.close() 205 | elif advice is Return: 206 | return 207 | elif isinstance(advice, Return): 208 | return advice.value 209 | else: 210 | raise UnacceptableAdvice(f'Unknown advice {advice}') 211 | finally: 212 | advisor.close() 213 | 214 | return mimic(advising_asyncgenerator_wrapper_py35, cutpoint_function) 215 | elif isgeneratorfunction(cutpoint_function): 216 | assert isgeneratorfunction(cutpoint_function) 217 | 218 | def advising_generator_wrapper_py35(*args, **kwargs): 219 | if self.bind: 220 | advisor = self.advising_function(cutpoint_function, *args, **kwargs) 221 | else: 222 | advisor = self.advising_function(*args, **kwargs) 223 | if not isgenerator(advisor): 224 | raise ExpectedGenerator(f'advising_function {self.advising_function} did not return a generator.') 225 | try: 226 | advice = next(advisor) 227 | while True: 228 | logdebug('Got advice %r from %s', advice, self.advising_function) 229 | if advice is Proceed or advice is None or isinstance(advice, Proceed): 230 | if isinstance(advice, Proceed): 231 | args = advice.args 232 | kwargs = advice.kwargs 233 | gen = cutpoint_function(*args, **kwargs) 234 | try: 235 | result = yield from gen 236 | except BaseException: 237 | advice = advisor.throw(*sys.exc_info()) 238 | else: 239 | try: 240 | advice = advisor.send(result) 241 | except StopIteration: 242 | return result 243 | finally: 244 | gen.close() 245 | elif advice is Return: 246 | return 247 | elif isinstance(advice, Return): 248 | return advice.value 249 | else: 250 | raise UnacceptableAdvice(f'Unknown advice {advice}') 251 | finally: 252 | advisor.close() 253 | 254 | return mimic(advising_generator_wrapper_py35, cutpoint_function) 255 | else: 256 | 257 | def advising_function_wrapper(*args, **kwargs): 258 | if self.bind: 259 | advisor = self.advising_function(cutpoint_function, *args, **kwargs) 260 | else: 261 | advisor = self.advising_function(*args, **kwargs) 262 | if not isgenerator(advisor): 263 | raise ExpectedGenerator(f'advising_function {self.advising_function} did not return a generator.') 264 | try: 265 | advice = next(advisor) 266 | while True: 267 | logdebug('Got advice %r from %s', advice, self.advising_function) 268 | if advice is Proceed or advice is None or isinstance(advice, Proceed): 269 | if isinstance(advice, Proceed): 270 | args = advice.args 271 | kwargs = advice.kwargs 272 | try: 273 | result = cutpoint_function(*args, **kwargs) 274 | except Exception: 275 | advice = advisor.throw(*sys.exc_info()) 276 | else: 277 | try: 278 | advice = advisor.send(result) 279 | except StopIteration: 280 | return result 281 | elif advice is Return: 282 | return 283 | elif isinstance(advice, Return): 284 | return advice.value 285 | else: 286 | raise UnacceptableAdvice(f'Unknown advice {advice}') 287 | finally: 288 | advisor.close() 289 | 290 | return mimic(advising_function_wrapper, cutpoint_function) 291 | 292 | 293 | class Fabric: 294 | pass 295 | 296 | 297 | class Rollback: 298 | """ 299 | When called, rollbacks all the patches and changes the :func:`weave` has done. 300 | """ 301 | 302 | __slots__ = ('_rollbacks',) 303 | 304 | def __init__(self, rollback=None): 305 | if rollback is None: 306 | self._rollbacks = [] 307 | elif isinstance(rollback, (list, tuple)): 308 | self._rollbacks = rollback 309 | else: 310 | self._rollbacks = [rollback] 311 | 312 | def merge(self, *others): 313 | self._rollbacks.extend(others) 314 | 315 | def __enter__(self): 316 | return self 317 | 318 | def __exit__(self, *_): 319 | for rollback in self._rollbacks: 320 | rollback() 321 | del self._rollbacks[:] 322 | 323 | rollback = __call__ = __exit__ 324 | 325 | 326 | class ObjectBag: 327 | def __init__(self): 328 | self._objects = {} 329 | 330 | def has(self, obj): 331 | if id(obj) in self._objects: 332 | logdebug(' --- ObjectBag ALREADY HAS %r', obj) 333 | return True 334 | else: 335 | self._objects[id(obj)] = obj 336 | return False 337 | 338 | 339 | BrokenBag = type('BrokenBag', (), {'has': lambda self, obj: False})() 340 | 341 | 342 | class EmptyRollback: 343 | def __enter__(self): 344 | return self 345 | 346 | def __exit__(self, *_): 347 | pass 348 | 349 | rollback = __call__ = __exit__ 350 | 351 | 352 | Nothing = EmptyRollback() 353 | 354 | 355 | def _checked_apply(aspects, function, module=None): 356 | logdebug(' applying aspects %s to function %s.', aspects, function) 357 | if callable(aspects): 358 | wrapper = aspects(function) 359 | assert callable(wrapper), f'Aspect {aspects} did not return a callable (it return {wrapper}).' 360 | else: 361 | wrapper = function 362 | for aspect in aspects: 363 | wrapper = aspect(wrapper) 364 | assert callable(wrapper), f'Aspect {aspect} did not return a callable (it return {wrapper}).' 365 | return mimic(wrapper, function, module=module) 366 | 367 | 368 | def _check_name(name): 369 | if not VALID_IDENTIFIER.match(name): 370 | raise SyntaxError( 371 | f'Could not match {name!r} to {VALID_IDENTIFIER.pattern!r}. It should be a string of ' 372 | 'letters, numbers and underscore that starts with a letter or underscore.' 373 | ) 374 | 375 | 376 | def weave(target, aspects, **options): 377 | """ 378 | Send a message to a recipient 379 | 380 | Args: 381 | target (string, class, instance, function or builtin): 382 | The object to weave. 383 | aspects (:py:obj:`aspectlib.Aspect`, function decorator or list of): 384 | The aspects to apply to the object. 385 | subclasses (bool): 386 | If ``True``, subclasses of target are weaved. *Only available for classes* 387 | aliases (bool): 388 | If ``True``, aliases of target are replaced. 389 | lazy (bool): 390 | If ``True`` only target's ``__init__`` method is patched, the rest of the methods are patched after 391 | ``__init__`` is called. *Only available for classes*. 392 | methods (list or regex or string): 393 | Methods from target to patch. *Only available for classes* 394 | 395 | Returns: 396 | aspectlib.Rollback: An object that can rollback the patches. 397 | 398 | Raises: 399 | TypeError: If target is a unacceptable object, or the specified options are not available for that type of 400 | object. 401 | 402 | .. versionchanged:: 0.4.0 403 | 404 | Replaced `only_methods`, `skip_methods`, `skip_magicmethods` options with `methods`. 405 | Renamed `on_init` option to `lazy`. 406 | Added `aliases` option. 407 | Replaced `skip_subclasses` option with `subclasses`. 408 | """ 409 | if not callable(aspects): 410 | if not hasattr(aspects, '__iter__'): 411 | raise ExpectedAdvice(f'{aspects} must be an `Aspect` instance, a callable or an iterable of.') 412 | for obj in aspects: 413 | if not callable(obj): 414 | raise ExpectedAdvice(f'{obj} must be an `Aspect` instance or a callable.') 415 | assert target, f"Can't weave falsy value {target!r}." 416 | logdebug('weave (target=%s, aspects=%s, **options=%s)', target, aspects, options) 417 | 418 | bag = options.setdefault('bag', ObjectBag()) 419 | 420 | if isinstance(target, (list, tuple)): 421 | return Rollback([weave(item, aspects, **options) for item in target]) 422 | elif isinstance(target, basestring): 423 | parts = target.split('.') 424 | for part in parts: 425 | _check_name(part) 426 | 427 | if len(parts) == 1: 428 | return weave_module(_import_module(part), aspects, **options) 429 | 430 | for pos in reversed(range(1, len(parts))): 431 | owner, name = '.'.join(parts[:pos]), '.'.join(parts[pos:]) 432 | try: 433 | owner = _import_module(owner) 434 | except ImportError: 435 | continue 436 | else: 437 | break 438 | else: 439 | raise ImportError(f'Could not import {target!r}. Last try was for {owner}') 440 | 441 | if '.' in name: 442 | path, name = name.rsplit('.', 1) 443 | path = deque(path.split('.')) 444 | while path: 445 | owner = getattr(owner, path.popleft()) 446 | 447 | logdebug('@ patching %s from %s ...', name, owner) 448 | obj = getattr(owner, name) 449 | 450 | if isinstance(obj, (type, ClassType)): 451 | logdebug(' .. as a class %r.', obj) 452 | return weave_class(obj, aspects, owner=owner, name=name, **options) 453 | elif callable(obj): # or isinstance(obj, FunctionType) ?? 454 | logdebug(' .. as a callable %r.', obj) 455 | if bag.has(obj): 456 | return Nothing 457 | return patch_module_function(owner, obj, aspects, force_name=name, **options) 458 | else: 459 | return weave(obj, aspects, **options) 460 | 461 | name = getattr(target, '__name__', None) 462 | if name and getattr(builtins, name, None) is target: 463 | if bag.has(target): 464 | return Nothing 465 | return patch_module_function(builtins, target, aspects, **options) 466 | elif PY3 and ismethod(target): 467 | if bag.has(target): 468 | return Nothing 469 | inst = target.__self__ 470 | name = target.__name__ 471 | logdebug('@ patching %r (%s) as instance method.', target, name) 472 | func = target.__func__ 473 | setattr(inst, name, _checked_apply(aspects, func).__get__(inst, type(inst))) 474 | return Rollback(lambda: delattr(inst, name)) 475 | elif PY3 and isfunction(target): 476 | if bag.has(target): 477 | return Nothing 478 | owner = _import_module(target.__module__) 479 | path = deque(target.__qualname__.split('.')[:-1]) 480 | while path: 481 | owner = getattr(owner, path.popleft()) 482 | name = target.__name__ 483 | logdebug('@ patching %r (%s) as a property.', target, name) 484 | func = owner.__dict__[name] 485 | return patch_module(owner, name, _checked_apply(aspects, func), func, **options) 486 | elif isclass(target): 487 | return weave_class(target, aspects, **options) 488 | elif ismodule(target): 489 | return weave_module(target, aspects, **options) 490 | elif type(target).__module__ not in ('builtins', '__builtin__') or InstanceType and isinstance(target, InstanceType): 491 | return weave_instance(target, aspects, **options) 492 | else: 493 | raise UnsupportedType(f"Can't weave object {target} of type {type(target)}") 494 | 495 | 496 | def _rewrap_method(func, klass, aspect): 497 | if isinstance(func, staticmethod): 498 | if hasattr(func, '__func__'): 499 | return staticmethod(_checked_apply(aspect, func.__func__)) 500 | else: 501 | return staticmethod(_checked_apply(aspect, func.__get__(None, klass))) 502 | elif isinstance(func, classmethod): 503 | if hasattr(func, '__func__'): 504 | return classmethod(_checked_apply(aspect, func.__func__)) 505 | else: 506 | return classmethod(_checked_apply(aspect, func.__get__(None, klass).im_func)) 507 | else: 508 | return _checked_apply(aspect, func) 509 | 510 | 511 | def weave_instance(instance, aspect, methods=NORMAL_METHODS, lazy=False, bag=BrokenBag, **options): 512 | """ 513 | Low-level weaver for instances. 514 | 515 | .. warning:: You should not use this directly. 516 | 517 | :returns: An :obj:`aspectlib.Rollback` object. 518 | """ 519 | if bag.has(instance): 520 | return Nothing 521 | 522 | entanglement = Rollback() 523 | method_matches = make_method_matcher(methods) 524 | logdebug('weave_instance (module=%r, aspect=%s, methods=%s, lazy=%s, **options=%s)', instance, aspect, methods, lazy, options) 525 | 526 | def fixup(func): 527 | return func.__get__(instance, type(instance)) 528 | 529 | fixed_aspect = [*aspect, fixup] if isinstance(aspect, (list, tuple)) else [aspect, fixup] 530 | 531 | for attr in dir(instance): 532 | if method_matches(attr): 533 | func = getattr(instance, attr) 534 | if ismethod(func): 535 | if hasattr(func, '__func__'): 536 | realfunc = func.__func__ 537 | else: 538 | realfunc = func.im_func 539 | entanglement.merge(patch_module(instance, attr, _checked_apply(fixed_aspect, realfunc, module=None), **options)) 540 | return entanglement 541 | 542 | 543 | def weave_module(module, aspect, methods=NORMAL_METHODS, lazy=False, bag=BrokenBag, **options): 544 | """ 545 | Low-level weaver for "whole module weaving". 546 | 547 | .. warning:: You should not use this directly. 548 | 549 | :returns: An :obj:`aspectlib.Rollback` object. 550 | """ 551 | if bag.has(module): 552 | return Nothing 553 | 554 | entanglement = Rollback() 555 | method_matches = make_method_matcher(methods) 556 | logdebug('weave_module (module=%r, aspect=%s, methods=%s, lazy=%s, **options=%s)', module, aspect, methods, lazy, options) 557 | 558 | for attr in dir(module): 559 | if method_matches(attr): 560 | func = getattr(module, attr) 561 | if isroutine(func): 562 | entanglement.merge(patch_module_function(module, func, aspect, force_name=attr, **options)) 563 | elif isclass(func): 564 | entanglement.merge( 565 | weave_class(func, aspect, owner=module, name=attr, methods=methods, lazy=lazy, bag=bag, **options), 566 | # it's not consistent with the other ways of weaving a class (it's never weaved as a routine). 567 | # therefore it's disabled until it's considered useful. 568 | # #patch_module_function(module, getattr(module, attr), aspect, force_name=attr, **options), 569 | ) 570 | return entanglement 571 | 572 | 573 | def weave_class( 574 | klass, aspect, methods=NORMAL_METHODS, subclasses=True, lazy=False, owner=None, name=None, aliases=True, bases=True, bag=BrokenBag 575 | ): 576 | """ 577 | Low-level weaver for classes. 578 | 579 | .. warning:: You should not use this directly. 580 | """ 581 | assert isclass(klass), f"Can't weave {klass!r}. Must be a class." 582 | 583 | if bag.has(klass): 584 | return Nothing 585 | 586 | entanglement = Rollback() 587 | method_matches = make_method_matcher(methods) 588 | logdebug( 589 | 'weave_class (klass=%r, methods=%s, subclasses=%s, lazy=%s, owner=%s, name=%s, aliases=%s, bases=%s)', 590 | klass, 591 | methods, 592 | subclasses, 593 | lazy, 594 | owner, 595 | name, 596 | aliases, 597 | bases, 598 | ) 599 | 600 | if subclasses and hasattr(klass, '__subclasses__'): 601 | sub_targets = klass.__subclasses__() 602 | if sub_targets: 603 | logdebug('~ weaving subclasses: %s', sub_targets) 604 | for sub_class in sub_targets: 605 | if not issubclass(sub_class, Fabric): 606 | entanglement.merge(weave_class(sub_class, aspect, methods=methods, subclasses=subclasses, lazy=lazy, bag=bag)) 607 | if lazy: 608 | 609 | def __init__(self, *args, **kwargs): 610 | super(SubClass, self).__init__(*args, **kwargs) 611 | for attr in dir(self): 612 | if method_matches(attr) and attr not in wrappers: 613 | func = getattr(self, attr, None) 614 | if isroutine(func): 615 | setattr(self, attr, _checked_apply(aspect, force_bind(func)).__get__(self, SubClass)) 616 | 617 | wrappers = {'__init__': _checked_apply(aspect, __init__) if method_matches('__init__') else __init__} 618 | for attr, func in klass.__dict__.items(): 619 | if method_matches(attr): 620 | if ismethoddescriptor(func): 621 | wrappers[attr] = _rewrap_method(func, klass, aspect) 622 | 623 | logdebug(' * creating subclass with attributes %r', wrappers) 624 | name = name or klass.__name__ 625 | SubClass = type(name, (klass, Fabric), wrappers) 626 | SubClass.__module__ = klass.__module__ 627 | module = owner or _import_module(klass.__module__) 628 | entanglement.merge(patch_module(module, name, SubClass, original=klass, aliases=aliases)) 629 | else: 630 | original = {} 631 | for attr, func in klass.__dict__.items(): 632 | if method_matches(attr): 633 | if isroutine(func): 634 | logdebug('@ patching attribute %r (original: %r).', attr, func) 635 | setattr(klass, attr, _rewrap_method(func, klass, aspect)) 636 | else: 637 | continue 638 | original[attr] = func 639 | entanglement.merge(lambda: deque((setattr(klass, attr, func) for attr, func in original.items()), maxlen=0)) 640 | if bases: 641 | super_original = set() 642 | for sklass in _find_super_classes(klass): 643 | if sklass is not object: 644 | for attr, func in sklass.__dict__.items(): 645 | if method_matches(attr) and attr not in original and attr not in super_original: 646 | if isroutine(func): 647 | logdebug('@ patching attribute %r (from superclass: %s, original: %r).', attr, sklass.__name__, func) 648 | setattr(klass, attr, _rewrap_method(func, sklass, aspect)) 649 | else: 650 | continue 651 | super_original.add(attr) 652 | entanglement.merge(lambda: deque((delattr(klass, attr) for attr in super_original), maxlen=0)) 653 | 654 | return entanglement 655 | 656 | 657 | def _find_super_classes(klass): 658 | if hasattr(klass, '__mro__'): 659 | for k in klass.__mro__: 660 | yield k 661 | else: 662 | for base in klass.__bases__: 663 | yield base 664 | for k in _find_super_classes(base): 665 | yield k 666 | 667 | 668 | def _import_module(module): 669 | __import__(module) 670 | return sys.modules[module] 671 | 672 | 673 | def patch_module(module, name, replacement, original=UNSPECIFIED, aliases=True, location=None, **_bogus_options): 674 | """ 675 | Low-level attribute patcher. 676 | 677 | :param module module: Object to patch. 678 | :param str name: Attribute to patch 679 | :param replacement: The replacement value. 680 | :param original: The original value (in case the object beeing patched uses descriptors or is plain weird). 681 | :param bool aliases: If ``True`` patch all the attributes that have the same original value. 682 | 683 | :returns: An :obj:`aspectlib.Rollback` object. 684 | """ 685 | rollback = Rollback() 686 | seen = False 687 | original = getattr(module, name) if original is UNSPECIFIED else original 688 | location = module.__name__ if hasattr(module, '__name__') else type(module).__module__ 689 | target = module.__name__ if hasattr(module, '__name__') else type(module).__name__ 690 | try: 691 | replacement.__module__ = location 692 | except (TypeError, AttributeError): 693 | pass 694 | for alias in dir(module): 695 | logdebug('alias:%s (%s)', alias, name) 696 | if hasattr(module, alias): 697 | obj = getattr(module, alias) 698 | logdebug('- %s:%s (%s)', obj, original, obj is original) 699 | if obj is original: 700 | if aliases or alias == name: 701 | logdebug('= saving %s on %s.%s ...', replacement, target, alias) 702 | setattr(module, alias, replacement) 703 | rollback.merge(lambda alias=alias: setattr(module, alias, original)) 704 | if alias == name: 705 | seen = True 706 | elif alias == name: 707 | if ismethod(obj): 708 | logdebug('= saving %s on %s.%s ...', replacement, target, alias) 709 | setattr(module, alias, replacement) 710 | rollback.merge(lambda alias=alias: setattr(module, alias, original)) 711 | seen = True 712 | else: 713 | raise AssertionError(f'{module}.{alias} = {obj} is not {original}.') 714 | 715 | if not seen: 716 | warnings.warn( 717 | f'Setting {target}.{name} to {replacement}. There was no previous definition, probably patching the wrong module.', stacklevel=2 718 | ) 719 | logdebug('= saving %s on %s.%s ...', replacement, target, name) 720 | setattr(module, name, replacement) 721 | rollback.merge(lambda: setattr(module, name, original)) 722 | return rollback 723 | 724 | 725 | def patch_module_function(module, target, aspect, force_name=None, bag=BrokenBag, **options): 726 | """ 727 | Low-level patcher for one function from a specified module. 728 | 729 | .. warning:: You should not use this directly. 730 | 731 | :returns: An :obj:`aspectlib.Rollback` object. 732 | """ 733 | logdebug( 734 | 'patch_module_function (module=%s, target=%s, aspect=%s, force_name=%s, **options=%s', module, target, aspect, force_name, options 735 | ) 736 | name = force_name or target.__name__ 737 | return patch_module(module, name, _checked_apply(aspect, target, module=module), original=target, **options) 738 | -------------------------------------------------------------------------------- /src/aspectlib/contrib.py: -------------------------------------------------------------------------------- 1 | import time 2 | from logging import getLogger 3 | 4 | from aspectlib import Aspect 5 | 6 | logger = getLogger(__name__) 7 | 8 | 9 | def retry(func=None, retries=5, backoff=None, exceptions=(IOError, OSError, EOFError), cleanup=None, sleep=time.sleep): 10 | """ 11 | Decorator that retries the call ``retries`` times if ``func`` raises ``exceptions``. Can use a ``backoff`` function 12 | to sleep till next retry. 13 | 14 | Example:: 15 | 16 | >>> should_fail = lambda foo=[1,2,3]: foo and foo.pop() 17 | >>> @retry 18 | ... def flaky_func(): 19 | ... if should_fail(): 20 | ... raise OSError('Tough luck!') 21 | ... print("Success!") 22 | ... 23 | >>> flaky_func() 24 | Success! 25 | 26 | If it reaches the retry limit:: 27 | 28 | >>> @retry 29 | ... def bad_func(): 30 | ... raise OSError('Tough luck!') 31 | ... 32 | >>> bad_func() 33 | Traceback (most recent call last): 34 | ... 35 | OSError: Tough luck! 36 | 37 | """ 38 | 39 | @Aspect(bind=True) 40 | def retry_aspect(cutpoint, *args, **kwargs): 41 | for count in range(retries + 1): 42 | try: 43 | if count and cleanup: 44 | cleanup(*args, **kwargs) 45 | yield 46 | break 47 | except exceptions as exc: 48 | if count == retries: 49 | raise 50 | if not backoff: 51 | timeout = 0 52 | elif isinstance(backoff, (int, float)): 53 | timeout = backoff 54 | else: 55 | timeout = backoff(count) 56 | logger.exception( 57 | '%s(%s, %s) raised exception %s. %s retries left. Sleeping %s secs.', 58 | cutpoint.__name__, 59 | args, 60 | kwargs, 61 | exc, 62 | retries - count, 63 | timeout, 64 | ) 65 | sleep(timeout) 66 | 67 | return retry_aspect if func is None else retry_aspect(func) 68 | 69 | 70 | def exponential_backoff(count): 71 | """ 72 | Wait 2**N seconds. 73 | """ 74 | return 2**count 75 | 76 | 77 | retry.exponential_backoff = exponential_backoff 78 | 79 | 80 | def straight_backoff(count): 81 | """ 82 | Wait 1, 2, 5 seconds. All retries after the 3rd retry will wait 5*N-5 seconds. 83 | """ 84 | return (1, 2, 5)[count] if count < 3 else 5 * count - 5 85 | 86 | 87 | retry.straight_backoff = straight_backoff 88 | 89 | 90 | def flat_backoff(count): 91 | """ 92 | Wait 1, 2, 5, 10, 15, 30 and 60 seconds. All retries after the 5th retry will wait 60 seconds. 93 | """ 94 | return (1, 2, 5, 10, 15, 30, 60)[count if count < 6 else -1] 95 | 96 | 97 | retry.flat_backoff = flat_backoff 98 | -------------------------------------------------------------------------------- /src/aspectlib/debug.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import string 4 | import sys 5 | from itertools import islice 6 | 7 | from aspectlib import Aspect 8 | from aspectlib import mimic 9 | 10 | try: 11 | from types import InstanceType 12 | except ImportError: 13 | InstanceType = type(None) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def frame_iterator(frame): 19 | """ 20 | Yields frames till there are no more. 21 | """ 22 | while frame: 23 | yield frame 24 | frame = frame.f_back 25 | 26 | 27 | def format_stack(skip=0, length=6, _sep=os.path.sep): 28 | """ 29 | Returns a one-line string with the current callstack. 30 | """ 31 | return ' < '.join( 32 | '{}:{}:{}'.format('/'.join(f.f_code.co_filename.split(_sep)[-2:]), f.f_lineno, f.f_code.co_name) 33 | for f in islice(frame_iterator(sys._getframe(1 + skip)), length) 34 | ) 35 | 36 | 37 | PRINTABLE = string.digits + string.ascii_letters + string.punctuation + ' ' 38 | ASCII_ONLY = ''.join(i if i in PRINTABLE else '.' for i in (chr(c) for c in range(256))) 39 | 40 | 41 | def strip_non_ascii(val): 42 | """ 43 | Convert to string (using `str`) and replace non-ascii characters with a dot (``.``). 44 | """ 45 | return str(val).translate(ASCII_ONLY) 46 | 47 | 48 | def log( 49 | func=None, 50 | stacktrace=10, 51 | stacktrace_align=60, 52 | attributes=(), 53 | module=True, 54 | call=True, 55 | call_args=True, 56 | call_args_repr=repr, 57 | result=True, 58 | exception=True, 59 | exception_repr=repr, 60 | result_repr=strip_non_ascii, 61 | use_logging='CRITICAL', 62 | print_to=None, 63 | ): 64 | """ 65 | Decorates `func` to have logging. 66 | 67 | Args 68 | func (function): 69 | Function to decorate. If missing log returns a partial which you can use as a decorator. 70 | stacktrace (int): 71 | Number of frames to show. 72 | stacktrace_align (int): 73 | Column to align the framelist to. 74 | attributes (list): 75 | List of instance attributes to show, in case the function is a instance method. 76 | module (bool): 77 | Show the module. 78 | call (bool): 79 | If ``True``, then show calls. If ``False`` only show the call details on exceptions (if ``exception`` is 80 | enabled) (default: ``True``) 81 | call_args (bool): 82 | If ``True``, then show call arguments. (default: ``True``) 83 | call_args_repr (bool): 84 | Function to convert one argument to a string. (default: ``repr``) 85 | result (bool): 86 | If ``True``, then show result. (default: ``True``) 87 | exception (bool): 88 | If ``True``, then show exceptions. (default: ``True``) 89 | exception_repr (function): 90 | Function to convert an exception to a string. (default: ``repr``) 91 | result_repr (function): 92 | Function to convert the result object to a string. (default: ``strip_non_ascii`` - like ``str`` but nonascii 93 | characters are replaced with dots.) 94 | use_logging (string): 95 | Emit log messages with the given loglevel. (default: ``"CRITICAL"``) 96 | print_to (fileobject): 97 | File object to write to, in case you don't want to use logging module. (default: ``None`` - printing is 98 | disabled) 99 | 100 | Returns: 101 | A decorator or a wrapper. 102 | 103 | Example:: 104 | 105 | >>> @log(print_to=sys.stdout) 106 | ... def a(weird=False): 107 | ... if weird: 108 | ... raise RuntimeError('BOOM!') 109 | >>> a() 110 | a() <<< ... 111 | a => None 112 | >>> try: 113 | ... a(weird=True) 114 | ... except Exception: 115 | ... pass # naughty code! 116 | a(weird=True) <<< ... 117 | a ~ raised RuntimeError('BOOM!',) 118 | 119 | You can conveniently use this to logs just errors, or just results, example:: 120 | 121 | >>> import aspectlib 122 | >>> with aspectlib.weave(float, log(call=False, result=False, print_to=sys.stdout)): 123 | ... try: 124 | ... float('invalid') 125 | ... except Exception as e: 126 | ... pass # naughty code! 127 | float('invalid') <<< ... 128 | float ~ raised ValueError(...float...invalid...) 129 | 130 | This makes debugging naughty code easier. 131 | 132 | PS: Without the weaving it looks like this:: 133 | 134 | >>> try: 135 | ... log(call=False, result=False, print_to=sys.stdout)(float)('invalid') 136 | ... except Exception: 137 | ... pass # naughty code! 138 | float('invalid') <<< ... 139 | float ~ raised ValueError(...float...invalid...) 140 | 141 | 142 | .. versionchanged:: 0.5.0 143 | 144 | Renamed `arguments` to `call_args`. 145 | Renamed `arguments_repr` to `call_args_repr`. 146 | Added `call` option. 147 | """ 148 | 149 | loglevel = use_logging and (logging._levelNames if hasattr(logging, '_levelNames') else logging._nameToLevel).get( 150 | use_logging, logging.CRITICAL 151 | ) 152 | _missing = object() 153 | 154 | def dump(buf): 155 | try: 156 | if use_logging: 157 | logger._log(loglevel, buf, ()) 158 | if print_to: 159 | buf += '\n' 160 | print_to.write(buf) 161 | except Exception as exc: 162 | logger.critical('Failed to log a message: %s', exc, exc_info=True) 163 | 164 | class __logged__(Aspect): 165 | __slots__ = 'cutpoint_function', 'final_function', 'binding', '__name__', '__weakref__' 166 | 167 | bind = False 168 | 169 | def __init__(self, cutpoint_function, binding=None): 170 | mimic(self, cutpoint_function) 171 | self.cutpoint_function = cutpoint_function 172 | self.final_function = super().__call__(cutpoint_function) 173 | self.binding = binding 174 | 175 | def __get__(self, instance, owner): 176 | return __logged__(self.cutpoint_function.__get__(instance, owner), instance) 177 | 178 | def __call__(self, *args, **kwargs): 179 | return self.final_function(*args, **kwargs) 180 | 181 | def advising_function(self, *args, **kwargs): 182 | name = self.cutpoint_function.__name__ 183 | instance = self.binding 184 | if instance is not None: 185 | if isinstance(instance, InstanceType): 186 | instance_type = instance.__class__ 187 | else: 188 | instance_type = type(instance) 189 | 190 | info = [] 191 | for key in attributes: 192 | if key.endswith('()'): 193 | callarg = key = key.rstrip('()') 194 | else: 195 | callarg = False 196 | val = getattr(instance, key, _missing) 197 | if val is not _missing and key != name: 198 | info.append(f' {key}={call_args_repr(val() if callarg else val)}') 199 | sig = buf = '{{{}{}{}}}.{}'.format( 200 | instance_type.__module__ + '.' if module else '', instance_type.__name__, ''.join(info), name 201 | ) 202 | else: 203 | sig = buf = name 204 | if call_args: 205 | buf += '({}{})'.format( 206 | ', '.join(repr(i) for i in (args if call_args is True else args[:call_args])), 207 | ((', ' if args else '') + ', '.join('{}={!r}'.format(*i) for i in kwargs.items())) 208 | if kwargs and call_args is True 209 | else '', 210 | ) 211 | if stacktrace: 212 | buf = ('%%-%ds <<< %%s' % stacktrace_align) % (buf, format_stack(skip=1, length=stacktrace)) 213 | if call: 214 | dump(buf) 215 | try: 216 | res = yield 217 | except Exception as exc: 218 | if exception: 219 | if not call: 220 | dump(buf) 221 | dump(f'{sig} ~ raised {exception_repr(exc)}') 222 | raise 223 | 224 | if result: 225 | dump(f'{sig} => {result_repr(res)}') 226 | 227 | if func: 228 | return __logged__(func) 229 | else: 230 | return __logged__ 231 | -------------------------------------------------------------------------------- /src/aspectlib/pytestsupport.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aspectlib 4 | 5 | 6 | @pytest.fixture 7 | def weave(request): 8 | def autocleaned_weave(*args, **kwargs): 9 | entanglement = aspectlib.weave(*args, **kwargs) 10 | request.addfinalizer(entanglement.rollback) 11 | return entanglement 12 | 13 | return autocleaned_weave 14 | -------------------------------------------------------------------------------- /src/aspectlib/test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | from collections import namedtuple 4 | from difflib import unified_diff 5 | from functools import partial 6 | from functools import wraps 7 | from inspect import isclass 8 | from logging import _checkLevel 9 | from logging import getLevelName 10 | from logging import getLogger 11 | from sys import _getframe 12 | from traceback import format_stack 13 | 14 | from aspectlib import ALL_METHODS 15 | from aspectlib import mimic 16 | from aspectlib import weave 17 | 18 | from .utils import Sentinel 19 | from .utils import camelcase_to_underscores 20 | from .utils import container 21 | from .utils import logf 22 | from .utils import qualname 23 | from .utils import repr_ex 24 | 25 | try: 26 | from logging import _levelNames as nameToLevel 27 | except ImportError: 28 | from logging import _nameToLevel as nameToLevel 29 | try: 30 | from dummy_thread import allocate_lock 31 | except ImportError: 32 | try: 33 | from _dummy_thread import allocate_lock 34 | except ImportError: 35 | from _thread import allocate_lock 36 | 37 | from collections import ChainMap 38 | from collections import OrderedDict 39 | 40 | __all__ = 'mock', 'record', 'Story' 41 | 42 | logger = getLogger(__name__) 43 | logexception = logf(logger.exception) 44 | 45 | Call = namedtuple('Call', ('self', 'args', 'kwargs')) 46 | CallEx = namedtuple('CallEx', ('self', 'name', 'args', 'kwargs')) 47 | Result = namedtuple('Result', ('self', 'args', 'kwargs', 'result', 'exception')) 48 | ResultEx = namedtuple('ResultEx', ('self', 'name', 'args', 'kwargs', 'result', 'exception')) 49 | _INIT = Sentinel('INIT') 50 | 51 | 52 | def mock(return_value, call=False): 53 | """ 54 | Factory for a decorator that makes the function return a given `return_value`. 55 | 56 | Args: 57 | return_value: Value to return from the wrapper. 58 | call (bool): If ``True``, call the decorated function. (default: ``False``) 59 | 60 | Returns: 61 | A decorator. 62 | """ 63 | 64 | def mock_decorator(func): 65 | @wraps(func) 66 | def mock_wrapper(*args, **kwargs): 67 | if call: 68 | func(*args, **kwargs) 69 | return return_value 70 | 71 | return mock_wrapper 72 | 73 | return mock_decorator 74 | 75 | 76 | class LogCapture: 77 | """ 78 | Records all log messages made on the given logger. Assumes the logger has a ``_log`` method. 79 | 80 | Example:: 81 | 82 | >>> import logging 83 | >>> logger = logging.getLogger('mylogger') 84 | >>> with LogCapture(logger, level='INFO') as logs: 85 | ... logger.debug("Message from debug: %s", 'somearg') 86 | ... logger.info("Message from info: %s", 'somearg') 87 | ... logger.error("Message from error: %s", 'somearg') 88 | >>> logs.calls 89 | [('Message from info: %s', ('somearg',), 'INFO'), ('Message from error: %s', ('somearg',), 'ERROR')] 90 | >>> logs.messages 91 | [('INFO', 'Message from info: somearg'), ('ERROR', 'Message from error: somearg')] 92 | >>> logs.has('Message from info: %s') 93 | True 94 | >>> logs.has('Message from info: somearg') 95 | True 96 | >>> logs.has('Message from info: %s', 'badarg') 97 | False 98 | >>> logs.has('Message from debug: %s') 99 | False 100 | >>> logs.assertLogged('Message from error: %s') 101 | >>> logs.assertLogged('Message from error: %s') 102 | >>> logs.assertLogged('Message from error: %s') 103 | 104 | .. versionchanged:: 1.3.0 105 | 106 | Added ``messages`` property. 107 | Changed ``calls`` to retrun the level as a string (instead of int). 108 | """ 109 | 110 | def __init__(self, logger, level='DEBUG'): 111 | self._logger = logger 112 | self._level = nameToLevel[level] 113 | self._calls = [] 114 | self._rollback = None 115 | 116 | def __enter__(self): 117 | self._rollback = weave( 118 | self._logger, 119 | record(callback=self._callback, extended=True, iscalled=True), 120 | methods=('debug', 'info', 'warning', 'error', 'exception', 'critical', 'log'), 121 | ) 122 | return self 123 | 124 | def __exit__(self, *exc): 125 | self._rollback() 126 | 127 | def _callback(self, _binding, qualname, args, _kwargs): 128 | _, name = qualname.rsplit('.', 1) 129 | 130 | if name == 'log': 131 | level, args = _checkLevel(args[0]), args[1:] 132 | elif name == 'exception': 133 | level = logging.ERROR 134 | else: 135 | level = _checkLevel(name.upper()) 136 | 137 | if len(args) > 1: 138 | message, args = args[0], args[1:] 139 | else: 140 | message, args = args[0], () 141 | 142 | if level >= self._level: 143 | self._calls.append((message % args if args else message, message, args, getLevelName(level))) 144 | 145 | @property 146 | def calls(self): 147 | return [i[1:] for i in self._calls] 148 | 149 | @property 150 | def messages(self): 151 | return [(i[-1], i[0]) for i in self._calls] 152 | 153 | def has(self, message, *args, **kwargs): 154 | level = kwargs.pop('level', None) 155 | assert not kwargs, f'Unexpected arguments: {kwargs}' 156 | for call_final_message, call_message, call_args, call_level in self._calls: 157 | if level is None or level == call_level: 158 | if message == call_message and args == call_args if args else message == call_final_message or message == call_message: 159 | return True 160 | return False 161 | 162 | def assertLogged(self, message, *args, **kwargs): 163 | if not self.has(message, *args, **kwargs): 164 | raise AssertionError( 165 | f"There's no such message {message!r} (with args {args!r}) logged on {self._logger}. Logged messages where: {self.calls}" 166 | ) 167 | 168 | 169 | class _RecordingFunctionWrapper: 170 | """ 171 | Function wrapper that records calls and can be used as an weaver context manager. 172 | 173 | See :obj:`aspectlib.test.record` for arguments. 174 | """ 175 | 176 | def __init__(self, wrapped, iscalled=True, calls=None, callback=None, extended=False, results=False, recurse_lock=None, binding=None): 177 | assert not results or iscalled, '`iscalled` must be True if `results` is True' 178 | mimic(self, wrapped) 179 | self.__wrapped = wrapped 180 | self.__entanglement = None 181 | self.__iscalled = iscalled 182 | self.__binding = binding 183 | self.__callback = callback 184 | self.__extended = extended 185 | self.__results = results 186 | self.__recurse_lock = recurse_lock 187 | self.calls = [] if not callback and calls is None else calls 188 | 189 | def __call__(self, *args, **kwargs): 190 | record = not self.__recurse_lock or self.__recurse_lock.acquire(False) 191 | try: 192 | if self.__results: 193 | try: 194 | result = self.__wrapped(*args, **kwargs) 195 | except Exception as exc: 196 | if record: 197 | self.__record(args, kwargs, None, exc) 198 | raise 199 | else: 200 | if record: 201 | self.__record(args, kwargs, result, None) 202 | return result 203 | else: 204 | if record: 205 | self.__record(args, kwargs) 206 | if self.__iscalled: 207 | return self.__wrapped(*args, **kwargs) 208 | finally: 209 | if record and self.__recurse_lock: 210 | self.__recurse_lock.release() 211 | 212 | def __record(self, args, kwargs, *response): 213 | if self.__callback is not None: 214 | self.__callback(self.__binding, qualname(self), args, kwargs, *response) 215 | if self.calls is not None: 216 | if self.__extended: 217 | self.calls.append((ResultEx if response else CallEx)(self.__binding, qualname(self), args, kwargs, *response)) 218 | else: 219 | self.calls.append((Result if response else Call)(self.__binding, args, kwargs, *response)) 220 | 221 | def __get__(self, instance, owner): 222 | return _RecordingFunctionWrapper( 223 | self.__wrapped.__get__(instance, owner), 224 | iscalled=self.__iscalled, 225 | calls=self.calls, 226 | callback=self.__callback, 227 | extended=self.__extended, 228 | results=self.__results, 229 | binding=instance, 230 | ) 231 | 232 | def __enter__(self): 233 | self.__entanglement = weave(self.__wrapped, lambda _: self) 234 | return self 235 | 236 | def __exit__(self, *args): 237 | self.__entanglement.rollback() 238 | 239 | 240 | def record(func=None, recurse_lock_factory=allocate_lock, **options): 241 | """ 242 | Factory or decorator (depending if `func` is initially given). 243 | 244 | Args: 245 | callback (list): 246 | An a callable that is to be called with ``instance, function, args, kwargs``. 247 | calls (list): 248 | An object where the `Call` objects are appended. If not given and ``callback`` is not specified then a new list 249 | object will be created. 250 | iscalled (bool): 251 | If ``True`` the `func` will be called. (default: ``False``) 252 | extended (bool): 253 | If ``True`` the `func`'s ``__name__`` will also be included in the call list. (default: ``False``) 254 | results (bool): 255 | If ``True`` the results (and exceptions) will also be included in the call list. (default: ``False``) 256 | 257 | Returns: 258 | A wrapper that records all calls made to `func`. The history is available as a ``call`` 259 | property. If access to the function is too hard then you need to specify the history manually. 260 | 261 | Example: 262 | 263 | >>> @record 264 | ... def a(x, y, a, b): 265 | ... pass 266 | >>> a(1, 2, 3, b='c') 267 | >>> a.calls 268 | [Call(self=None, args=(1, 2, 3), kwargs={'b': 'c'})] 269 | 270 | 271 | Or, with your own history list:: 272 | 273 | >>> calls = [] 274 | >>> @record(calls=calls) 275 | ... def a(x, y, a, b): 276 | ... pass 277 | >>> a(1, 2, 3, b='c') 278 | >>> a.calls 279 | [Call(self=None, args=(1, 2, 3), kwargs={'b': 'c'})] 280 | >>> calls is a.calls 281 | True 282 | 283 | 284 | .. versionchanged:: 0.9.0 285 | 286 | Renamed `history` option to `calls`. 287 | Renamed `call` option to `iscalled`. 288 | Added `callback` option. 289 | Added `extended` option. 290 | """ 291 | if func: 292 | return _RecordingFunctionWrapper(func, recurse_lock=recurse_lock_factory(), **options) 293 | else: 294 | return partial(record, **options) 295 | 296 | 297 | class StoryResultWrapper: 298 | __slots__ = ('__recorder__',) # 299 | 300 | def __init__(self, recorder): 301 | self.__recorder__ = recorder 302 | 303 | def __eq__(self, result): 304 | self.__recorder__(_Returns(result)) 305 | 306 | def __pow__(self, exception): 307 | if not (isinstance(exception, BaseException) or isclass(exception) and issubclass(exception, BaseException)): 308 | raise RuntimeError(f'Value {exception!r} must be an exception type or instance.') 309 | self.__recorder__(_Raises(exception)) 310 | 311 | def __unsupported__(self, *args): 312 | raise TypeError('Unsupported operation. Only `==` (for results) and `**` (for exceptions) can be used.') 313 | 314 | for mm in ( 315 | '__add__', 316 | '__sub__', 317 | '__mul__', 318 | '__floordiv__', 319 | '__mod__', 320 | '__divmod__', 321 | '__lshift__', 322 | '__rshift__', 323 | '__and__', 324 | '__xor__', 325 | '__or__', 326 | '__div__', 327 | '__truediv__', 328 | '__radd__', 329 | '__rsub__', 330 | '__rmul__', 331 | '__rdiv__', 332 | '__rtruediv__', 333 | '__rfloordiv__', 334 | '__rmod__', 335 | '__rdivmod__', 336 | '__rpow__', 337 | '__rlshift__', 338 | '__rrshift__', 339 | '__rand__', 340 | '__rxor__', 341 | '__ror__', 342 | '__iadd__', 343 | '__isub__', 344 | '__imul__', 345 | '__idiv__', 346 | '__itruediv__', 347 | '__ifloordiv__', 348 | '__imod__', 349 | '__ipow__', 350 | '__ilshift__', 351 | '__irshift__', 352 | '__iand__', 353 | '__ixor__', 354 | '__ior__', 355 | '__neg__', 356 | '__pos__', 357 | '__abs__', 358 | '__invert__', 359 | '__complex__', 360 | '__int__', 361 | '__long__', 362 | '__float__', 363 | '__oct__', 364 | '__hex__', 365 | '__index__', 366 | '__coerce__', 367 | '__getslice__', 368 | '__setslice__', 369 | '__delslice__', 370 | '__len__', 371 | '__getitem__', 372 | '__reversed__', 373 | '__contains__', 374 | '__call__', 375 | '__lt__', 376 | '__le__', 377 | '__ne__', 378 | '__gt__', 379 | '__ge__', 380 | '__cmp__', 381 | '__rcmp__', 382 | '__nonzero__', 383 | ): 384 | exec(f'{mm} = __unsupported__') # noqa: S102 385 | 386 | 387 | class _StoryFunctionWrapper: 388 | def __init__(self, wrapped, handle, binding=None, owner=None): 389 | self._wrapped = wrapped 390 | self._name = wrapped.__name__ 391 | self._handle = handle 392 | self._binding = binding 393 | self._owner = owner 394 | 395 | @property 396 | def _qualname(self): 397 | return qualname(self) 398 | 399 | def __call__(self, *args, **kwargs): 400 | if self._binding is None: 401 | return StoryResultWrapper(partial(self._handle, None, self._qualname, args, kwargs)) 402 | else: 403 | if self._name == '__init__': 404 | self._handle(None, qualname(self._owner), args, kwargs, _Binds(self._binding)) 405 | else: 406 | return StoryResultWrapper(partial(self._handle, self._binding, self._name, args, kwargs)) 407 | 408 | def __get__(self, binding, owner): 409 | return mimic( 410 | type(self)( 411 | self._wrapped.__get__(binding, owner) if hasattr(self._wrapped, '__get__') else self._wrapped, 412 | handle=self._handle, 413 | binding=binding, 414 | owner=owner, 415 | ), 416 | self, 417 | ) 418 | 419 | 420 | class _ReplayFunctionWrapper(_StoryFunctionWrapper): 421 | def __call__(self, *args, **kwargs): 422 | if self._binding is None: 423 | return self._handle(None, self._qualname, args, kwargs, self._wrapped) 424 | else: 425 | if self._name == '__init__': 426 | self._handle(None, qualname(self._owner), args, kwargs, self._wrapped, _Binds(self._binding)) 427 | else: 428 | return self._handle(self._binding, self._name, args, kwargs, self._wrapped) 429 | 430 | 431 | class _RecordingBase: 432 | _target = None 433 | _options = None 434 | 435 | def __init__(self, target, **options): 436 | self._target = target 437 | self._options = options 438 | self._calls = OrderedDict() 439 | self._ids = {} 440 | self._instances = defaultdict(int) 441 | 442 | def _make_key(self, binding, name, args, kwargs): 443 | if binding is not None: 444 | binding, _ = self._ids[id(binding)] 445 | return (binding, name, ', '.join(repr_ex(i) for i in args), ', '.join(f'{k}={repr_ex(v)}' for k, v in kwargs.items())) 446 | 447 | def _tag_result(self, name, result): 448 | if isinstance(result, _Binds): 449 | instance_name = camelcase_to_underscores(name.rsplit('.', 1)[-1]) 450 | self._instances[instance_name] += 1 451 | instance_name = f'{instance_name}_{self._instances[instance_name]}' 452 | self._ids[id(result.value)] = instance_name, result.value 453 | result.value = instance_name 454 | else: 455 | result.value = repr_ex(result.value, self._ids) 456 | return result 457 | 458 | def _handle(self, binding, name, args, kwargs, result): 459 | pk = self._make_key(binding, name, args, kwargs) 460 | result = self._tag_result(name, result) 461 | assert pk not in self._calls or self._calls[pk] == result, ( 462 | 'Story creation inconsistency. There is already a result cached for ' 463 | f"binding:{binding!r} name:{name!r} args:{args!r} kwargs:{kwargs!r} and it's: {self._calls[pk]!r}." 464 | ) 465 | self._calls[pk] = result 466 | 467 | def __enter__(self): 468 | self._options.setdefault('methods', ALL_METHODS) 469 | self.__entanglement = weave(self._target, partial(self._FunctionWrapper, handle=self._handle), **self._options) 470 | return self 471 | 472 | def __exit__(self, *args): 473 | self.__entanglement.rollback() 474 | del self._ids 475 | 476 | 477 | _Raises = container('Raises') 478 | _Returns = container('Returns') 479 | _Binds = container('Binds') 480 | 481 | 482 | class Story(_RecordingBase): 483 | """ 484 | This a simple yet flexible tool that can do "capture-replay mocking" or "test doubles" [1]_. It leverages 485 | ``aspectlib``'s powerful :obj:`weaver `. 486 | 487 | Args: 488 | target (same as for :obj:`aspectlib.weave`): 489 | Targets to weave in the `story`/`replay` transactions. 490 | subclasses (bool): 491 | If ``True``, subclasses of target are weaved. *Only available for classes* 492 | aliases (bool): 493 | If ``True``, aliases of target are replaced. 494 | lazy (bool): 495 | If ``True`` only target's ``__init__`` method is patched, the rest of the methods are patched after ``__init__`` 496 | is called. *Only available for classes*. 497 | methods (list or regex or string): Methods from target to patch. *Only available for classes* 498 | 499 | The ``Story`` allows some testing patterns that are hard to do with other tools: 500 | 501 | * **Proxied mocks**: partially mock `objects` and `modules` so they are called normally if the request is unknown. 502 | * **Stubs**: completely mock `objects` and `modules`. Raise errors if the request is unknown. 503 | 504 | The ``Story`` works in two of transactions: 505 | 506 | * **The story**: You describe what calls you want to mocked. Initially you don't need to write this. Example: 507 | 508 | :: 509 | 510 | >>> import mymod 511 | >>> with Story(mymod) as story: 512 | ... mymod.func('some arg') == 'some result' 513 | ... mymod.func('bad arg') ** ValueError("can't use this") 514 | 515 | * **The replay**: You run the code uses the interfaces mocked in the `story`. The :obj:`replay 516 | ` always starts from a `story` instance. 517 | 518 | .. versionchanged:: 0.9.0 519 | 520 | Added in. 521 | 522 | .. [1] http://www.martinfowler.com/bliki/TestDouble.html 523 | """ 524 | 525 | _FunctionWrapper = _StoryFunctionWrapper 526 | 527 | def __init__(self, *args, **kwargs): 528 | super().__init__(*args, **kwargs) 529 | frame = _getframe(1) 530 | self._context = frame.f_globals, frame.f_locals 531 | 532 | def replay(self, **options): 533 | """ 534 | Args: 535 | proxy (bool): 536 | If ``True`` then unexpected uses are allowed (will use the real functions) but they are collected for later 537 | use. Default: ``True``. 538 | strict (bool): 539 | If ``True`` then an ``AssertionError`` is raised when there were `unexpected calls` or there were `missing 540 | calls` (specified in the story but not called). Default: ``True``. 541 | dump (bool): 542 | If ``True`` then the `unexpected`/`missing calls` will be printed (to ``sys.stdout``). Default: ``True``. 543 | 544 | Returns: 545 | A :obj:`aspectlib.test.Replay` object. 546 | 547 | Example: 548 | 549 | >>> import mymod 550 | >>> with Story(mymod) as story: 551 | ... mymod.func('some arg') == 'some result' 552 | ... mymod.func('other arg') == 'other result' 553 | >>> with story.replay(strict=False): 554 | ... print(mymod.func('some arg')) 555 | ... mymod.func('bogus arg') 556 | some result 557 | Got bogus arg in the real code! 558 | STORY/REPLAY DIFF: 559 | --- expected... 560 | +++ actual... 561 | @@ -1,2 +1,2 @@ 562 | mymod.func('some arg') == 'some result' # returns 563 | -mymod.func('other arg') == 'other result' # returns 564 | +mymod.func('bogus arg') == None # returns 565 | ACTUAL: 566 | mymod.func('some arg') == 'some result' # returns 567 | mymod.func('bogus arg') == None # returns 568 | 569 | """ 570 | options.update(self._options) 571 | return Replay(self, **options) 572 | 573 | 574 | ReplayPair = namedtuple('ReplayPair', ('expected', 'actual')) 575 | 576 | 577 | def logged_eval(value, context): 578 | try: 579 | return eval(value, *context) # noqa: S307 580 | except: 581 | logexception('Failed to evaluate %r.\nContext:\n%s', value, ''.join(format_stack(f=_getframe(1), limit=15))) 582 | raise 583 | 584 | 585 | class Replay(_RecordingBase): 586 | """ 587 | Object implementing the `replay transaction`. 588 | 589 | This object should be created by :obj:`Story `'s :obj:`replay ` 590 | method. 591 | """ 592 | 593 | _FunctionWrapper = _ReplayFunctionWrapper 594 | 595 | def __init__(self, play, proxy=True, strict=True, dump=True, recurse_lock=False, **options): 596 | super().__init__(play._target, **options) 597 | self._calls, self._expected, self._actual = ChainMap(self._calls, play._calls), play._calls, self._calls 598 | 599 | self._proxy = proxy 600 | self._strict = strict 601 | self._dump = dump 602 | self._context = play._context 603 | self._recurse_lock = allocate_lock() if recurse_lock is True else (recurse_lock and recurse_lock()) 604 | 605 | def _handle(self, binding, name, args, kwargs, wrapped, bind=None): 606 | pk = self._make_key(binding, name, args, kwargs) 607 | if pk in self._expected: 608 | result = self._actual[pk] = self._expected[pk] 609 | if isinstance(result, _Binds): 610 | self._tag_result(name, bind) 611 | elif isinstance(result, _Returns): 612 | return logged_eval(result.value, self._context) 613 | elif isinstance(result, _Raises): 614 | raise logged_eval(result.value, self._context) 615 | else: 616 | raise RuntimeError(f'Internal failure - unknown result: {result!r}') # pragma: no cover 617 | else: 618 | if self._proxy: 619 | shouldrecord = not self._recurse_lock or self._recurse_lock.acquire(False) 620 | try: 621 | try: 622 | if bind: 623 | bind = self._tag_result(name, bind) 624 | result = wrapped(*args, **kwargs) 625 | except Exception as exc: 626 | if shouldrecord: 627 | self._calls[pk] = self._tag_result(name, _Raises(exc)) 628 | raise 629 | else: 630 | if shouldrecord: 631 | self._calls[pk] = bind or self._tag_result(name, _Returns(result)) 632 | return result 633 | finally: 634 | if shouldrecord and self._recurse_lock: 635 | self._recurse_lock.release() 636 | else: 637 | raise AssertionError('Unexpected call to {}/{} with args:{} kwargs:{}'.format(*pk)) 638 | 639 | def _unexpected(self, _missing=False): 640 | if _missing: 641 | expected, actual = self._actual, self._expected 642 | else: 643 | actual, expected = self._actual, self._expected 644 | return ''.join(_format_calls(OrderedDict((pk, val) for pk, val in actual.items() if pk not in expected or val != expected.get(pk)))) 645 | 646 | @property 647 | def unexpected(self): 648 | """ 649 | Returns a pretty text representation of just the unexpected calls. 650 | 651 | The output should be usable directly in the story (just copy-paste it). Example:: 652 | 653 | >>> import mymod 654 | >>> with Story(mymod) as story: 655 | ... pass 656 | >>> with story.replay(strict=False, dump=False) as replay: 657 | ... mymod.func('some arg') 658 | ... try: 659 | ... mymod.badfunc() 660 | ... except ValueError as exc: 661 | ... print(exc) 662 | Got some arg in the real code! 663 | boom! 664 | >>> print(replay.unexpected) 665 | mymod.func('some arg') == None # returns 666 | mymod.badfunc() ** ValueError('boom!',) # raises 667 | 668 | 669 | We can just take the output and paste in the story:: 670 | 671 | >>> import mymod 672 | >>> with Story(mymod) as story: 673 | ... mymod.func('some arg') == None # returns 674 | ... mymod.badfunc() ** ValueError('boom!') # raises 675 | >>> with story.replay(): 676 | ... mymod.func('some arg') 677 | ... try: 678 | ... mymod.badfunc() 679 | ... except ValueError as exc: 680 | ... print(exc) 681 | boom! 682 | 683 | """ 684 | return self._unexpected() 685 | 686 | @property 687 | def missing(self): 688 | """ 689 | Returns a pretty text representation of just the missing calls. 690 | """ 691 | return self._unexpected(_missing=True) 692 | 693 | @property 694 | def diff(self): 695 | """ 696 | Returns a pretty text representation of the unexpected and missing calls. 697 | 698 | Most of the time you don't need to directly use this. This is useful when you run the `replay` in 699 | ``strict=False`` mode and want to do custom assertions. 700 | 701 | """ 702 | actual = list(_format_calls(self._actual)) 703 | expected = list(_format_calls(self._expected)) 704 | return ''.join(unified_diff(expected, actual, fromfile='expected', tofile='actual')) 705 | 706 | @property 707 | def actual(self): 708 | return ''.join(_format_calls(self._actual)) 709 | 710 | @property 711 | def expected(self): 712 | return ''.join(_format_calls(self._expected)) 713 | 714 | def __exit__(self, *exception): 715 | super().__exit__() 716 | if self._strict or self._dump: 717 | diff = self.diff 718 | if diff: 719 | if exception or self._dump: 720 | print('STORY/REPLAY DIFF:') 721 | print(' ' + '\n '.join(diff.splitlines())) 722 | print('ACTUAL:') 723 | print(' ' + ' '.join(_format_calls(self._actual))) 724 | if not exception and self._strict: 725 | raise AssertionError(diff) 726 | 727 | 728 | def _format_calls(calls): 729 | for (binding, name, args, kwargs), result in calls.items(): 730 | sig = '{}({}{}{})'.format(name, args, ', ' if kwargs and args else '', kwargs) 731 | 732 | if isinstance(result, _Binds): 733 | yield f'{result.value} = {sig}\n' 734 | elif isinstance(result, _Returns): 735 | if binding is None: 736 | yield f'{sig} == {result.value} # returns\n' 737 | else: 738 | yield f'{binding}.{sig} == {result.value} # returns\n' 739 | elif isinstance(result, _Raises): 740 | if binding is None: 741 | yield f'{sig} ** {result.value} # raises\n' 742 | else: 743 | yield f'{binding}.{sig} ** {result.value} # raises\n' 744 | -------------------------------------------------------------------------------- /src/aspectlib/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import platform 4 | import re 5 | import sys 6 | from collections import deque 7 | from functools import wraps 8 | from inspect import isclass 9 | 10 | RegexType = type(re.compile('')) 11 | 12 | PY3 = sys.version_info[0] == 3 13 | PY310 = PY3 and sys.version_info[1] >= 10 14 | PYPY = platform.python_implementation() == 'PyPy' 15 | 16 | if PY3: 17 | basestring = str 18 | else: 19 | basestring = str, unicode # noqa 20 | 21 | FIRST_CAP_RE = re.compile('(.)([A-Z][a-z]+)') 22 | ALL_CAP_RE = re.compile('([a-z0-9])([A-Z])') 23 | 24 | DEBUG = os.getenv('ASPECTLIB_DEBUG') 25 | 26 | 27 | def logf(logger_func): 28 | @wraps(logger_func) 29 | def log_wrapper(*args): 30 | if DEBUG: 31 | logProcesses = logging.logProcesses 32 | logThreads = logging.logThreads 33 | logMultiprocessing = logging.logMultiprocessing 34 | logging.logThreads = logging.logProcesses = logMultiprocessing = False 35 | # disable logging pids and tids - we don't want extra calls around, especilly when we monkeypatch stuff 36 | try: 37 | return logger_func(*args) 38 | finally: 39 | logging.logProcesses = logProcesses 40 | logging.logThreads = logThreads 41 | logging.logMultiprocessing = logMultiprocessing 42 | 43 | return log_wrapper 44 | 45 | 46 | def camelcase_to_underscores(name): 47 | s1 = FIRST_CAP_RE.sub(r'\1_\2', name) 48 | return ALL_CAP_RE.sub(r'\1_\2', s1).lower() 49 | 50 | 51 | def qualname(obj): 52 | if hasattr(obj, '__module__') and obj.__module__ not in ('builtins', 'exceptions'): 53 | return f'{obj.__module__}.{obj.__name__}' 54 | else: 55 | return obj.__name__ 56 | 57 | 58 | def force_bind(func): 59 | def bound(self, *args, **kwargs): # pylint: disable=W0613 60 | return func(*args, **kwargs) 61 | 62 | bound.__name__ = func.__name__ 63 | bound.__doc__ = func.__doc__ 64 | return bound 65 | 66 | 67 | def make_method_matcher(regex_or_regexstr_or_namelist): 68 | if isinstance(regex_or_regexstr_or_namelist, basestring): 69 | return re.compile(regex_or_regexstr_or_namelist).match 70 | elif isinstance(regex_or_regexstr_or_namelist, (list, tuple)): 71 | return regex_or_regexstr_or_namelist.__contains__ 72 | elif isinstance(regex_or_regexstr_or_namelist, RegexType): 73 | return regex_or_regexstr_or_namelist.match 74 | else: 75 | raise TypeError(f'Unacceptable methods spec {regex_or_regexstr_or_namelist!r}.') 76 | 77 | 78 | class Sentinel: 79 | def __init__(self, name, doc=''): 80 | self.name = name 81 | self.__doc__ = doc 82 | 83 | def __repr__(self): 84 | if not self.__doc__: 85 | return f'{self.name}' 86 | else: 87 | return f'{self.name}: {self.__doc__}' 88 | 89 | __str__ = __repr__ 90 | 91 | 92 | def container(name): 93 | def __init__(self, value): 94 | self.value = value 95 | 96 | return type( 97 | name, 98 | (object,), 99 | { 100 | '__slots__': 'value', 101 | '__init__': __init__, 102 | '__str__': lambda self: f'{name}({self.value})', 103 | '__repr__': lambda self: f'{name}({self.value!r})', 104 | '__eq__': lambda self, other: type(self) is type(other) and self.value == other.value, 105 | }, 106 | ) 107 | 108 | 109 | def mimic(wrapper, func, module=None): 110 | try: 111 | wrapper.__name__ = func.__name__ 112 | except (TypeError, AttributeError): 113 | pass 114 | try: 115 | wrapper.__module__ = module or func.__module__ 116 | except (TypeError, AttributeError): 117 | pass 118 | try: 119 | wrapper.__doc__ = func.__doc__ 120 | except (TypeError, AttributeError): 121 | pass 122 | return wrapper 123 | 124 | 125 | representers = { 126 | tuple: lambda obj, aliases: '({}{})'.format(', '.join(repr_ex(i) for i in obj), ',' if len(obj) == 1 else ''), 127 | list: lambda obj, aliases: '[{}]'.format(', '.join(repr_ex(i) for i in obj)), 128 | set: lambda obj, aliases: 'set([{}])'.format(', '.join(repr_ex(i) for i in obj)), 129 | frozenset: lambda obj, aliases: 'set([{}])'.format(', '.join(repr_ex(i) for i in obj)), 130 | deque: lambda obj, aliases: 'collections.deque([{}])'.format(', '.join(repr_ex(i) for i in obj)), 131 | dict: lambda obj, aliases: '{{{}}}'.format( 132 | ', '.join(f'{repr_ex(k)}: {repr_ex(v)}' for k, v in (obj.items() if PY3 else obj.iteritems())) 133 | ), 134 | } 135 | 136 | 137 | def _make_fixups(): 138 | for obj in ('os.stat_result', 'grp.struct_group', 'pwd.struct_passwd'): 139 | mod, attr = obj.split('.') 140 | try: 141 | yield getattr(__import__(mod), attr), lambda obj, aliases, prefix=obj: f'{prefix}({obj.__reduce__()[1][0]!r})' 142 | except ImportError: 143 | continue 144 | 145 | 146 | representers.update(_make_fixups()) 147 | 148 | 149 | def repr_ex(obj, aliases=()): 150 | kind, ident = type(obj), id(obj) 151 | if isinstance(kind, BaseException): 152 | return '{}({})'.format(qualname(type(obj)), ', '.join(repr_ex(i, aliases) for i in obj.args)) 153 | elif isclass(obj): 154 | return qualname(obj) 155 | elif kind in representers: 156 | return representers[kind](obj, aliases) 157 | elif ident in aliases: 158 | return aliases[ident][0] 159 | else: 160 | return repr(obj) 161 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def pytest_ignore_collect(collection_path: Path, config): 5 | if 'pytestsupport' in collection_path.__fspath__(): 6 | return True 7 | -------------------------------------------------------------------------------- /tests/mymod.py: -------------------------------------------------------------------------------- 1 | def func(arg): 2 | print('Got', arg, 'in the real code!') 3 | 4 | 5 | def badfunc(): 6 | raise ValueError('boom!') 7 | -------------------------------------------------------------------------------- /tests/test_aspectlib_debug.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import sys 4 | import weakref 5 | 6 | import pytest 7 | 8 | import aspectlib 9 | import aspectlib.debug 10 | 11 | try: 12 | from StringIO import StringIO 13 | except ImportError: 14 | from io import StringIO 15 | 16 | LOG_TEST_SIMPLE = ( 17 | r"""^some_meth\(1, 2, 3, a=4\) +<<< .*tests/test_aspectlib_debug.py:\d+:test_simple.* 18 | some_meth => \.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\.\. !"#\$%&\'\(\)\*\+,-\./0123456789:;<=>\?@""" 19 | r"""ABCDEFGHIJKLMNOPQRSTUVWXYZ\[\\\]\^_`abcdefghijklmnopqrstuvwxyz\{\|\}~\.+ 20 | $""" 21 | ) 22 | 23 | 24 | def some_meth(*_args, **_kwargs): 25 | return ''.join(chr(i) for i in range(255)) 26 | 27 | 28 | class MyStuff: 29 | def __init__(self, foo): 30 | self.foo = foo 31 | 32 | def bar(self): 33 | return 'foo' 34 | 35 | def stuff(self): 36 | return self.foo 37 | 38 | 39 | class OldStuff: 40 | def __init__(self, foo): 41 | self.foo = foo 42 | 43 | def bar(self): 44 | return 'foo' 45 | 46 | def stuff(self): 47 | return self.foo 48 | 49 | 50 | def test_simple(): 51 | buf = StringIO() 52 | with aspectlib.weave(some_meth, aspectlib.debug.log(print_to=buf, module=False, stacktrace=10)): 53 | some_meth(1, 2, 3, a=4) 54 | 55 | assert re.match(LOG_TEST_SIMPLE, buf.getvalue()) 56 | some_meth(1, 2, 3, a=4) 57 | assert re.match(LOG_TEST_SIMPLE, buf.getvalue()) 58 | 59 | 60 | def test_fail_to_log(): 61 | @aspectlib.debug.log(print_to='crap') 62 | def foo(): 63 | pass 64 | 65 | foo() 66 | 67 | 68 | def test_logging_works(): 69 | buf = StringIO() 70 | ch = logging.StreamHandler(buf) 71 | ch.setLevel(logging.DEBUG) 72 | aspectlib.debug.logger.addHandler(ch) 73 | 74 | @aspectlib.debug.log 75 | def foo(): 76 | pass 77 | 78 | foo() 79 | assert re.match(r'foo\(\) +<<<.*\nfoo => None\n', buf.getvalue()) 80 | 81 | 82 | def test_attributes(): 83 | buf = StringIO() 84 | with aspectlib.weave( 85 | MyStuff, aspectlib.debug.log(print_to=buf, stacktrace=10, attributes=('foo', 'bar()')), methods='(?!bar)(?!__.*__$)' 86 | ): 87 | MyStuff('bar').stuff() 88 | print(buf.getvalue()) 89 | assert re.match( 90 | r"^\{test_aspectlib_debug.MyStuff foo='bar' bar='foo'\}.stuff\(\) +<<< .*tests/test_aspectlib_debug.py:\d+:" 91 | r"test_attributes.*\n\{test_aspectlib_debug.MyStuff foo='bar' bar='foo'\}.stuff => bar\n$", 92 | buf.getvalue(), 93 | ) 94 | MyStuff('bar').stuff() 95 | assert re.match( 96 | r"^\{test_aspectlib_debug.MyStuff foo='bar' bar='foo'\}.stuff\(\) +<<< .*tests/test_aspectlib_debug.py:\d+:" 97 | r"test_attributes.*\n\{test_aspectlib_debug.MyStuff foo='bar' bar='foo'\}.stuff => bar\n$", 98 | buf.getvalue(), 99 | ) 100 | 101 | 102 | def test_no_stack(): 103 | buf = StringIO() 104 | with aspectlib.weave( 105 | MyStuff, aspectlib.debug.log(print_to=buf, stacktrace=None, attributes=('foo', 'bar()')), methods='(?!bar)(?!__.*__$)' 106 | ): 107 | MyStuff('bar').stuff() 108 | print(buf.getvalue()) 109 | assert ( 110 | "{test_aspectlib_debug.MyStuff foo='bar' bar='foo'}.stuff()\n" 111 | "{test_aspectlib_debug.MyStuff foo='bar' bar='foo'}.stuff => bar\n" == buf.getvalue() 112 | ) 113 | 114 | 115 | def test_attributes_old_style(): 116 | buf = StringIO() 117 | with aspectlib.weave( 118 | OldStuff, aspectlib.debug.log(print_to=buf, stacktrace=10, attributes=('foo', 'bar()')), methods='(?!bar)(?!__.*__$)' 119 | ): 120 | OldStuff('bar').stuff() 121 | print(repr(buf.getvalue())) 122 | assert re.match( 123 | r"^\{test_aspectlib_debug.OldStuff foo='bar' bar='foo'\}.stuff\(\) +<<< .*tests/test_aspectlib_debug.py:\d+:" 124 | r"test_attributes.*\n\{test_aspectlib_debug.OldStuff foo='bar' bar='foo'\}.stuff => bar\n$", 125 | buf.getvalue(), 126 | ) 127 | MyStuff('bar').stuff() 128 | assert re.match( 129 | r"^\{test_aspectlib_debug.OldStuff foo='bar' bar='foo'\}.stuff\(\) +<<< .*tests/test_aspectlib_debug.py:\d+:" 130 | r"test_attributes.*\n\{test_aspectlib_debug.OldStuff foo='bar' bar='foo'\}.stuff => bar\n$", 131 | buf.getvalue(), 132 | ) 133 | 134 | 135 | def test_no_stack_old_style(): 136 | buf = StringIO() 137 | with aspectlib.weave( 138 | OldStuff, aspectlib.debug.log(print_to=buf, stacktrace=None, attributes=('foo', 'bar()')), methods='(?!bar)(?!__.*__$)' 139 | ): 140 | OldStuff('bar').stuff() 141 | print(buf.getvalue()) 142 | assert ( 143 | "{test_aspectlib_debug.OldStuff foo='bar' bar='foo'}.stuff()\n" 144 | "{test_aspectlib_debug.OldStuff foo='bar' bar='foo'}.stuff => bar\n" == buf.getvalue() 145 | ) 146 | 147 | 148 | @pytest.mark.skipif(sys.version_info < (2, 7), reason='No weakref.WeakSet on Python<=2.6') 149 | def test_weakref(): 150 | with aspectlib.weave(MyStuff, aspectlib.debug.log): 151 | s = weakref.WeakSet() 152 | s.add(MyStuff.stuff) 153 | print(list(s)) 154 | print(list(s)) 155 | 156 | 157 | @pytest.mark.skipif(sys.version_info < (2, 7), reason='No weakref.WeakSet on Python<=2.6') 158 | def test_weakref_oldstyle(): 159 | with aspectlib.weave(OldStuff, aspectlib.debug.log): 160 | s = weakref.WeakSet() 161 | s.add(MyStuff.stuff) 162 | print(list(s)) 163 | print(list(s)) 164 | -------------------------------------------------------------------------------- /tests/test_aspectlib_py3.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aspectlib 4 | 5 | 6 | def test_aspect_chain_on_generator(): 7 | @aspectlib.Aspect 8 | def foo(arg): 9 | result = yield aspectlib.Proceed(arg + 1) 10 | yield aspectlib.Return(result - 1) 11 | 12 | @foo 13 | @foo 14 | @foo 15 | def func(a): 16 | assert a == 3 17 | return a 18 | yield 19 | 20 | gen = func(0) 21 | result = pytest.raises(StopIteration, gen.__next__ if hasattr(gen, '__next__') else gen.next) 22 | assert result.value.args == (0,) 23 | 24 | 25 | def test_aspect_chain_on_generator_no_return(): 26 | @aspectlib.Aspect 27 | def foo(arg): 28 | result = yield aspectlib.Proceed(arg + 1) 29 | yield aspectlib.Return(result) 30 | 31 | @foo 32 | @foo 33 | @foo 34 | def func(a): 35 | assert a == 3 36 | yield 37 | 38 | result = consume(func(0)) 39 | assert result is None 40 | 41 | 42 | def consume(gen): 43 | ret = [] 44 | 45 | def it(): 46 | ret.append((yield from gen)) 47 | 48 | list(it()) 49 | return ret[0] 50 | 51 | 52 | def test_aspect_chain_on_generator_yield_from(): 53 | @aspectlib.Aspect 54 | def foo(arg): 55 | result = yield aspectlib.Proceed(arg + 1) 56 | yield aspectlib.Return(result - 1) 57 | 58 | @foo 59 | @foo 60 | @foo 61 | def func(a): 62 | assert a == 3 63 | return a 64 | yield 65 | 66 | gen = func(0) 67 | assert consume(gen) == 0 68 | 69 | 70 | def test_aspect_chain_on_generator_no_return_yield_from(): 71 | @aspectlib.Aspect 72 | def foo(arg): 73 | result = yield aspectlib.Proceed(arg + 1) 74 | yield aspectlib.Return(result) 75 | 76 | @foo 77 | @foo 78 | @foo 79 | def func(a): 80 | assert a == 3 81 | yield 82 | 83 | gen = func(0) 84 | assert consume(gen) is None 85 | -------------------------------------------------------------------------------- /tests/test_aspectlib_py37.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aspectlib 4 | from test_aspectlib_py3 import consume 5 | 6 | 7 | def test_aspect_on_generator_result(): 8 | result = [] 9 | 10 | @aspectlib.Aspect 11 | def aspect(): 12 | result.append((yield aspectlib.Proceed)) 13 | 14 | @aspect 15 | def func(): 16 | yield 'something' 17 | return 'value' 18 | 19 | assert list(func()) == ['something'] 20 | assert result == ['value'] 21 | 22 | 23 | def test_aspect_on_coroutine(): 24 | hist = [] 25 | 26 | @aspectlib.Aspect 27 | def aspect(): 28 | try: 29 | hist.append('before') 30 | hist.append((yield aspectlib.Proceed)) 31 | hist.append('after') 32 | except Exception: 33 | hist.append('error') 34 | finally: 35 | hist.append('finally') 36 | try: 37 | hist.append((yield aspectlib.Return)) 38 | except GeneratorExit: 39 | hist.append('closed') 40 | raise 41 | else: 42 | hist.append('consumed') 43 | hist.append('bad-suffix') 44 | 45 | @aspect 46 | def func(): 47 | val = 99 48 | for _ in range(3): 49 | print('YIELD', val + 1) 50 | val = yield val + 1 51 | print('GOT', val) 52 | return 'the-return-value' 53 | 54 | gen = func() 55 | data = [] 56 | try: 57 | for i in [None, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]: 58 | data.append(gen.send(i)) 59 | except StopIteration: 60 | data.append('done') 61 | print(data) 62 | assert data == [100, 1, 2, 'done'], hist 63 | print(hist) 64 | assert hist == ['before', 'the-return-value', 'after', 'finally', 'closed'] 65 | 66 | 67 | def test_aspect_chain_on_generator(): 68 | @aspectlib.Aspect 69 | def foo(arg): 70 | result = yield aspectlib.Proceed(arg + 1) 71 | yield aspectlib.Return(result - 1) 72 | 73 | @foo 74 | @foo 75 | @foo 76 | def func(a): 77 | assert a == 3 78 | return a 79 | yield 80 | 81 | gen = func(0) 82 | result = pytest.raises(StopIteration, gen.__next__ if hasattr(gen, '__next__') else gen.next) 83 | assert result.value.args == (0,) 84 | 85 | 86 | def test_aspect_chain_on_generator_no_return_advice(): 87 | @aspectlib.Aspect 88 | def foo(arg): 89 | yield aspectlib.Proceed(arg + 1) 90 | 91 | @foo 92 | @foo 93 | @foo 94 | def func(a): 95 | assert a == 3 96 | return a 97 | yield 98 | 99 | gen = func(0) 100 | assert consume(gen) == 3 101 | 102 | 103 | def test_aspect_on_generator_raise_stopiteration(): 104 | result = [] 105 | 106 | @aspectlib.Aspect 107 | def aspect(): 108 | val = yield aspectlib.Proceed 109 | result.append(val) 110 | 111 | @aspect 112 | def func(): 113 | return 'something' 114 | yield 115 | 116 | assert list(func()) == [] 117 | assert result == ['something'] 118 | 119 | 120 | def test_aspect_on_generator_result_from_aspect(): 121 | @aspectlib.Aspect 122 | def aspect(): 123 | yield aspectlib.Proceed 124 | yield aspectlib.Return('result') 125 | 126 | @aspect 127 | def func(): 128 | yield 'something' 129 | 130 | gen = func() 131 | try: 132 | while 1: 133 | next(gen) 134 | except StopIteration as exc: 135 | assert exc.args == ('result',) # noqa: PT017 136 | else: 137 | raise AssertionError('did not raise StopIteration') 138 | 139 | 140 | def test_aspect_chain_on_generator_no_return(): 141 | @aspectlib.Aspect 142 | def foo(arg): 143 | result = yield aspectlib.Proceed(arg + 1) 144 | yield aspectlib.Return(result) 145 | 146 | @foo 147 | @foo 148 | @foo 149 | def func(a): 150 | assert a == 3 151 | yield 152 | 153 | gen = func(0) 154 | assert consume(gen) is None 155 | -------------------------------------------------------------------------------- /tests/test_aspectlib_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aspectlib.test import OrderedDict 4 | from aspectlib.test import Story 5 | from aspectlib.test import StoryResultWrapper 6 | from aspectlib.test import _Binds 7 | from aspectlib.test import _format_calls 8 | from aspectlib.test import _Raises 9 | from aspectlib.test import _Returns 10 | from aspectlib.test import mock 11 | from aspectlib.test import record 12 | from aspectlib.utils import PY310 13 | from aspectlib.utils import repr_ex 14 | from test_pkg1.test_pkg2 import test_mod 15 | 16 | pytest_plugins = ('pytester',) 17 | 18 | 19 | def format_calls(calls): 20 | return ''.join(_format_calls(calls)) 21 | 22 | 23 | def module_fun(a, b=2): 24 | pass 25 | 26 | 27 | def module_fun2(a, b=2): 28 | pass 29 | 30 | 31 | exc = RuntimeError() 32 | 33 | 34 | def rfun(): 35 | raise exc 36 | 37 | 38 | def nfun(a, b=2): 39 | return a, b 40 | 41 | 42 | def test_record(): 43 | fun = record(nfun) 44 | 45 | assert fun(2, 3) == (2, 3) 46 | assert fun(3, b=4) == (3, 4) 47 | assert fun.calls == [ 48 | (None, (2, 3), {}), 49 | (None, (3,), {'b': 4}), 50 | ] 51 | 52 | 53 | def test_record_result(): 54 | fun = record(results=True)(nfun) 55 | 56 | assert fun(2, 3) == (2, 3) 57 | assert fun(3, b=4) == (3, 4) 58 | assert fun.calls == [ 59 | (None, (2, 3), {}, (2, 3), None), 60 | (None, (3,), {'b': 4}, (3, 4), None), 61 | ] 62 | 63 | 64 | def test_record_exception(): 65 | fun = record(results=True)(rfun) 66 | 67 | pytest.raises(RuntimeError, fun) 68 | assert fun.calls == [ 69 | (None, (), {}, None, exc), 70 | ] 71 | 72 | 73 | def test_record_result_callback(): 74 | calls = [] 75 | 76 | fun = record(results=True, callback=lambda *args: calls.append(args))(nfun) 77 | 78 | assert fun(2, 3) == (2, 3) 79 | assert fun(3, b=4) == (3, 4) 80 | assert calls == [ 81 | (None, 'test_aspectlib_test.nfun', (2, 3), {}, (2, 3), None), 82 | (None, 'test_aspectlib_test.nfun', (3,), {'b': 4}, (3, 4), None), 83 | ] 84 | 85 | 86 | def test_record_exception_callback(): 87 | calls = [] 88 | 89 | fun = record(results=True, callback=lambda *args: calls.append(args))(rfun) 90 | 91 | pytest.raises(RuntimeError, fun) 92 | assert calls == [ 93 | (None, 'test_aspectlib_test.rfun', (), {}, None, exc), 94 | ] 95 | 96 | 97 | def test_record_callback(): 98 | calls = [] 99 | 100 | fun = record(callback=lambda *args: calls.append(args))(nfun) 101 | 102 | assert fun(2, 3) == (2, 3) 103 | assert fun(3, b=4) == (3, 4) 104 | assert calls == [ 105 | (None, 'test_aspectlib_test.nfun', (2, 3), {}), 106 | (None, 'test_aspectlib_test.nfun', (3,), {'b': 4}), 107 | ] 108 | 109 | 110 | def test_record_with_no_call(): 111 | called = [] 112 | 113 | @record(iscalled=False) 114 | def fun(): 115 | called.append(True) 116 | 117 | assert fun() is None 118 | assert fun.calls == [ 119 | (None, (), {}), 120 | ] 121 | assert called == [] 122 | 123 | 124 | def test_record_with_call(): 125 | called = [] 126 | 127 | @record 128 | def fun(): 129 | called.append(True) 130 | 131 | fun() 132 | assert fun.calls == [ 133 | (None, (), {}), 134 | ] 135 | assert called == [True] 136 | 137 | 138 | def test_record_as_context(): 139 | with record(module_fun) as history: 140 | module_fun(2, 3) 141 | module_fun(3, b=4) 142 | 143 | assert history.calls == [ 144 | (None, (2, 3), {}), 145 | (None, (3,), {'b': 4}), 146 | ] 147 | del history.calls[:] 148 | 149 | module_fun(2, 3) 150 | module_fun(3, b=4) 151 | assert history.calls == [] 152 | 153 | 154 | def test_bad_mock(): 155 | pytest.raises(TypeError, mock) 156 | pytest.raises(TypeError, mock, call=False) 157 | 158 | 159 | def test_simple_mock(): 160 | assert 'foobar' == mock('foobar')(module_fun)(1) 161 | 162 | 163 | def test_mock_no_calls(): 164 | with record(module_fun) as history: 165 | assert 'foobar' == mock('foobar')(module_fun)(2) 166 | assert history.calls == [] 167 | 168 | 169 | def test_mock_with_calls(): 170 | with record(module_fun) as history: 171 | assert 'foobar' == mock('foobar', call=True)(module_fun)(3) 172 | assert history.calls == [(None, (3,), {})] 173 | 174 | 175 | def test_double_recording(): 176 | with record(module_fun) as history: 177 | with record(module_fun2) as history2: 178 | module_fun(2, 3) 179 | module_fun2(2, 3) 180 | 181 | assert history.calls == [ 182 | (None, (2, 3), {}), 183 | ] 184 | del history.calls[:] 185 | assert history2.calls == [ 186 | (None, (2, 3), {}), 187 | ] 188 | del history2.calls[:] 189 | 190 | module_fun(2, 3) 191 | assert history.calls == [] 192 | assert history2.calls == [] 193 | 194 | 195 | def test_record_not_iscalled_and_results(): 196 | pytest.raises(AssertionError, record, module_fun, iscalled=False, results=True) 197 | record(module_fun, iscalled=False, results=False) 198 | record(module_fun, iscalled=True, results=True) 199 | record(module_fun, iscalled=True, results=False) 200 | 201 | 202 | def test_story_empty_play_noproxy(): 203 | with Story(test_mod).replay(recurse_lock=True, proxy=False, strict=False) as replay: 204 | pytest.raises(AssertionError, test_mod.target) 205 | 206 | assert replay._actual == {} 207 | 208 | 209 | def test_story_empty_play_proxy(): 210 | assert test_mod.target() is None 211 | pytest.raises(TypeError, test_mod.target, 123) 212 | 213 | with Story(test_mod).replay(recurse_lock=True, proxy=True, strict=False) as replay: 214 | assert test_mod.target() is None 215 | pytest.raises(TypeError, test_mod.target, 123) 216 | 217 | assert format_calls(replay._actual) == format_calls( 218 | OrderedDict( 219 | [ 220 | ((None, 'test_pkg1.test_pkg2.test_mod.target', '', ''), _Returns('None')), 221 | ( 222 | (None, 'test_pkg1.test_pkg2.test_mod.target', '123', ''), 223 | _Raises( 224 | repr_ex( 225 | TypeError( 226 | 'target() takes 0 positional arguments but 1 was given', 227 | ) 228 | ) 229 | ), 230 | ), 231 | ] 232 | ) 233 | ) 234 | 235 | 236 | def test_story_empty_play_noproxy_class(): 237 | with Story(test_mod).replay(recurse_lock=True, proxy=False, strict=False) as replay: 238 | pytest.raises(AssertionError, test_mod.Stuff, 1, 2) 239 | 240 | assert replay._actual == {} 241 | 242 | 243 | def test_story_empty_play_error_on_init(): 244 | with Story(test_mod).replay(strict=False) as replay: 245 | pytest.raises(ValueError, test_mod.Stuff, 'error') # noqa: PT011 246 | print(replay._actual) 247 | assert replay._actual == OrderedDict([((None, 'test_pkg1.test_pkg2.test_mod.Stuff', "'error'", ''), _Raises('ValueError()'))]) 248 | 249 | 250 | def test_story_half_play_noproxy_class(): 251 | with Story(test_mod) as story: 252 | obj = test_mod.Stuff(1, 2) 253 | 254 | with story.replay(recurse_lock=True, proxy=False, strict=False): 255 | obj = test_mod.Stuff(1, 2) 256 | pytest.raises(AssertionError, obj.mix, 3, 4) 257 | 258 | 259 | def test_xxx(): 260 | with Story(test_mod) as story: 261 | obj = test_mod.Stuff(1, 2) 262 | test_mod.target(1) == 2 # noqa: B015 263 | test_mod.target(2) == 3 # noqa: B015 264 | test_mod.target(3) ** ValueError 265 | other = test_mod.Stuff(2, 2) 266 | obj.other('a') == other # noqa: B015 267 | obj.meth('a') == 'x' # noqa: B015 268 | obj = test_mod.Stuff(2, 3) 269 | obj.meth() ** ValueError('crappo') 270 | obj.meth('c') == 'x' # noqa: B015 271 | 272 | with story.replay(recurse_lock=True, strict=False) as replay: 273 | obj = test_mod.Stuff(1, 2) 274 | obj.meth('a') 275 | test_mod.target(1) 276 | obj.meth() 277 | test_mod.func(5) 278 | 279 | obj = test_mod.Stuff(4, 4) 280 | obj.meth() 281 | 282 | for k, v in story._calls.items(): 283 | print(k, '=>', v) 284 | print('############## UNEXPECTED ##############') 285 | for k, v in replay._actual.items(): 286 | print(k, '=>', v) 287 | 288 | # TODO 289 | 290 | 291 | def test_story_text_helpers(): 292 | with Story(test_mod) as story: 293 | obj = test_mod.Stuff(1, 2) 294 | obj.meth('a') == 'x' # noqa: B015 295 | obj.meth('b') == 'y' # noqa: B015 296 | obj = test_mod.Stuff(2, 3) 297 | obj.meth('c') == 'z' # noqa: B015 298 | test_mod.target(1) == 2 # noqa: B015 299 | test_mod.target(2) == 3 # noqa: B015 300 | 301 | with story.replay(recurse_lock=True, strict=False) as replay: 302 | obj = test_mod.Stuff(1, 2) 303 | obj.meth('a') 304 | obj.meth() 305 | obj = test_mod.Stuff(4, 4) 306 | obj.meth() 307 | test_mod.func(5) 308 | test_mod.target(1) 309 | 310 | print(replay.missing) 311 | assert ( 312 | replay.missing 313 | == """stuff_1.meth('b') == 'y' # returns 314 | stuff_2 = test_pkg1.test_pkg2.test_mod.Stuff(2, 3) 315 | stuff_2.meth('c') == 'z' # returns 316 | test_pkg1.test_pkg2.test_mod.target(2) == 3 # returns 317 | """ 318 | ) 319 | print(replay.unexpected) 320 | assert ( 321 | replay.unexpected 322 | == """stuff_1.meth() == None # returns 323 | stuff_2 = test_pkg1.test_pkg2.test_mod.Stuff(4, 4) 324 | stuff_2.meth() == None # returns 325 | test_pkg1.test_pkg2.test_mod.func(5) == None # returns 326 | """ 327 | ) 328 | print(replay.diff) 329 | assert ( 330 | replay.diff 331 | == """--- expected 332 | +++ actual 333 | @@ -1,7 +1,7 @@ 334 | stuff_1 = test_pkg1.test_pkg2.test_mod.Stuff(1, 2) 335 | stuff_1.meth('a') == 'x' # returns 336 | -stuff_1.meth('b') == 'y' # returns 337 | -stuff_2 = test_pkg1.test_pkg2.test_mod.Stuff(2, 3) 338 | -stuff_2.meth('c') == 'z' # returns 339 | +stuff_1.meth() == None # returns 340 | +stuff_2 = test_pkg1.test_pkg2.test_mod.Stuff(4, 4) 341 | +stuff_2.meth() == None # returns 342 | +test_pkg1.test_pkg2.test_mod.func(5) == None # returns 343 | test_pkg1.test_pkg2.test_mod.target(1) == 2 # returns 344 | -test_pkg1.test_pkg2.test_mod.target(2) == 3 # returns 345 | """ 346 | ) 347 | 348 | 349 | def test_story_empty_play_proxy_class_missing_report(LineMatcher): 350 | with Story(test_mod).replay(recurse_lock=True, proxy=True, strict=False) as replay: 351 | obj = test_mod.Stuff(1, 2) 352 | obj.mix(3, 4) 353 | obj.mix('a', 'b') 354 | pytest.raises(ValueError, obj.raises, 123) # noqa: PT011 355 | obj = test_mod.Stuff(0, 1) 356 | obj.mix('a', 'b') 357 | obj.mix(3, 4) 358 | test_mod.target() 359 | pytest.raises(ValueError, test_mod.raises, 'badarg') # noqa: PT011 360 | pytest.raises(ValueError, obj.raises, 123) # noqa: PT011 361 | test_mod.ThatLONGStuf(1).mix(2) 362 | test_mod.ThatLONGStuf(3).mix(4) 363 | obj = test_mod.ThatLONGStuf(2) 364 | obj.mix() 365 | obj.meth() 366 | obj.mix(10) 367 | LineMatcher(replay.diff.splitlines()).fnmatch_lines( 368 | [ 369 | '--- expected', 370 | '+++ actual', 371 | '@@ -0,0 +1,18 @@', 372 | '+stuff_1 = test_pkg1.test_pkg2.test_mod.Stuff(1, 2)', 373 | '+stuff_1.mix(3, 4) == (1, 2, 3, 4) # returns', 374 | "+stuff_1.mix('a', 'b') == (1, 2, 'a', 'b') # returns", 375 | '+stuff_1.raises(123) ** ValueError((123,)*) # raises', 376 | '+stuff_2 = test_pkg1.test_pkg2.test_mod.Stuff(0, 1)', 377 | "+stuff_2.mix('a', 'b') == (0, 1, 'a', 'b') # returns", 378 | '+stuff_2.mix(3, 4) == (0, 1, 3, 4) # returns', 379 | '+test_pkg1.test_pkg2.test_mod.target() == None # returns', 380 | "+test_pkg1.test_pkg2.test_mod.raises('badarg') ** ValueError(('badarg',)*) # raises", 381 | '+stuff_2.raises(123) ** ValueError((123,)*) # raises', 382 | '+that_long_stuf_1 = test_pkg1.test_pkg2.test_mod.ThatLONGStuf(1)', 383 | '+that_long_stuf_1.mix(2) == (1, 2) # returns', 384 | '+that_long_stuf_2 = test_pkg1.test_pkg2.test_mod.ThatLONGStuf(3)', 385 | '+that_long_stuf_2.mix(4) == (3, 4) # returns', 386 | '+that_long_stuf_3 = test_pkg1.test_pkg2.test_mod.ThatLONGStuf(2)', 387 | '+that_long_stuf_3.mix() == (2,) # returns', 388 | '+that_long_stuf_3.meth() == None # returns', 389 | '+that_long_stuf_3.mix(10) == (2, 10) # returns', 390 | ] 391 | ) 392 | 393 | 394 | def test_story_empty_play_proxy_class(): 395 | assert test_mod.Stuff(1, 2).mix(3, 4) == (1, 2, 3, 4) 396 | 397 | with Story(test_mod).replay(recurse_lock=True, proxy=True, strict=False) as replay: 398 | obj = test_mod.Stuff(1, 2) 399 | assert obj.mix(3, 4) == (1, 2, 3, 4) 400 | assert obj.mix('a', 'b') == (1, 2, 'a', 'b') 401 | 402 | pytest.raises(TypeError, obj.meth, 123) 403 | 404 | obj = test_mod.Stuff(0, 1) 405 | assert obj.mix('a', 'b') == (0, 1, 'a', 'b') 406 | assert obj.mix(3, 4) == (0, 1, 3, 4) 407 | 408 | pytest.raises(TypeError, obj.meth, 123) 409 | 410 | assert format_calls(replay._actual) == format_calls( 411 | OrderedDict( 412 | [ 413 | ((None, 'test_pkg1.test_pkg2.test_mod.Stuff', '1, 2', ''), _Binds('stuff_1')), 414 | (('stuff_1', 'mix', '3, 4', ''), _Returns('(1, 2, 3, 4)')), 415 | (('stuff_1', 'mix', "'a', 'b'", ''), _Returns("(1, 2, 'a', 'b')")), 416 | ( 417 | ('stuff_1', 'meth', '123', ''), 418 | _Raises( 419 | repr_ex( 420 | TypeError( 421 | 'Stuff.meth() takes 1 positional argument but 2 were given' 422 | if PY310 423 | else 'meth() takes 1 positional argument but 2 were given' 424 | ) 425 | ) 426 | ), 427 | ), 428 | ((None, 'test_pkg1.test_pkg2.test_mod.Stuff', '0, 1', ''), _Binds('stuff_2')), 429 | (('stuff_2', 'mix', "'a', 'b'", ''), _Returns("(0, 1, 'a', 'b')")), 430 | (('stuff_2', 'mix', '3, 4', ''), _Returns('(0, 1, 3, 4)')), 431 | ( 432 | ('stuff_2', 'meth', '123', ''), 433 | _Raises( 434 | repr_ex( 435 | TypeError( 436 | 'Stuff.meth() takes 1 positional argument but 2 were given' 437 | if PY310 438 | else 'meth() takes 1 positional argument but 2 were given' 439 | ) 440 | ) 441 | ), 442 | ), 443 | ] 444 | ) 445 | ) 446 | 447 | 448 | def test_story_half_play_proxy_class(): 449 | assert test_mod.Stuff(1, 2).mix(3, 4) == (1, 2, 3, 4) 450 | 451 | with Story(test_mod) as story: 452 | obj = test_mod.Stuff(1, 2) 453 | obj.mix(3, 4) == (1, 2, 3, 4) # noqa: B015 454 | 455 | with story.replay(recurse_lock=True, proxy=True, strict=False) as replay: 456 | obj = test_mod.Stuff(1, 2) 457 | assert obj.mix(3, 4) == (1, 2, 3, 4) 458 | assert obj.meth() is None 459 | 460 | pytest.raises(TypeError, obj.meth, 123) 461 | 462 | obj = test_mod.Stuff(0, 1) 463 | assert obj.mix('a', 'b') == (0, 1, 'a', 'b') 464 | assert obj.mix(3, 4) == (0, 1, 3, 4) 465 | 466 | pytest.raises(TypeError, obj.meth, 123) 467 | assert replay.unexpected == format_calls( 468 | OrderedDict( 469 | [ 470 | (('stuff_1', 'meth', '', ''), _Returns('None')), 471 | ( 472 | ('stuff_1', 'meth', '123', ''), 473 | _Raises( 474 | repr_ex( 475 | TypeError( 476 | 'Stuff.meth() takes 1 positional argument but 2 were given' 477 | if PY310 478 | else 'meth() takes 1 positional argument but 2 were given' 479 | ) 480 | ) 481 | ), 482 | ), 483 | ((None, 'test_pkg1.test_pkg2.test_mod.Stuff', '0, 1', ''), _Binds('stuff_2')), 484 | (('stuff_2', 'mix', "'a', 'b'", ''), _Returns("(0, 1, 'a', 'b')")), 485 | (('stuff_2', 'mix', '3, 4', ''), _Returns('(0, 1, 3, 4)')), 486 | ( 487 | ('stuff_2', 'meth', '123', ''), 488 | _Raises( 489 | repr_ex( 490 | TypeError( 491 | 'Stuff.meth() takes 1 positional argument but 2 were given' 492 | if PY310 493 | else 'meth() takes 1 positional argument but 2 were given' 494 | ) 495 | ) 496 | ), 497 | ), 498 | ] 499 | ) 500 | ) 501 | 502 | 503 | def test_story_full_play_noproxy(): 504 | with Story(test_mod) as story: 505 | test_mod.target(123) == 'foobar' # noqa: B015 506 | test_mod.target(1234) ** ValueError 507 | 508 | with story.replay(recurse_lock=True, proxy=False, strict=False, dump=False) as replay: 509 | pytest.raises(AssertionError, test_mod.target) 510 | assert test_mod.target(123) == 'foobar' 511 | pytest.raises(ValueError, test_mod.target, 1234) # noqa: PT011 512 | 513 | assert replay.unexpected == '' 514 | 515 | 516 | def test_story_full_play_noproxy_dump(): 517 | with Story(test_mod) as story: 518 | test_mod.target(123) == 'foobar' # noqa: B015 519 | test_mod.target(1234) ** ValueError 520 | 521 | with story.replay(recurse_lock=True, proxy=False, strict=False, dump=True) as replay: 522 | pytest.raises(AssertionError, test_mod.target) 523 | assert test_mod.target(123) == 'foobar' 524 | pytest.raises(ValueError, test_mod.target, 1234) # noqa: PT011 525 | 526 | assert replay.unexpected == '' 527 | 528 | 529 | def test_story_full_play_proxy(): 530 | with Story(test_mod) as story: 531 | test_mod.target(123) == 'foobar' # noqa: B015 532 | test_mod.target(1234) ** ValueError 533 | 534 | with story.replay(recurse_lock=True, proxy=True, strict=False) as replay: 535 | assert test_mod.target() is None 536 | assert test_mod.target(123) == 'foobar' 537 | pytest.raises(ValueError, test_mod.target, 1234) # noqa: PT011 538 | pytest.raises(TypeError, test_mod.target, 'asdf') 539 | 540 | assert replay.unexpected == format_calls( 541 | OrderedDict( 542 | [ 543 | ((None, 'test_pkg1.test_pkg2.test_mod.target', '', ''), _Returns('None')), 544 | ( 545 | (None, 'test_pkg1.test_pkg2.test_mod.target', "'asdf'", ''), 546 | _Raises( 547 | repr_ex( 548 | TypeError( 549 | 'target() takes 0 positional arguments but 1 was given', 550 | ) 551 | ) 552 | ), 553 | ), 554 | ] 555 | ) 556 | ) 557 | 558 | 559 | def test_story_result_wrapper(): 560 | x = StoryResultWrapper(lambda *a: None) 561 | pytest.raises(AttributeError, setattr, x, 'stuff', 1) 562 | pytest.raises(AttributeError, getattr, x, 'stuff') 563 | pytest.raises(TypeError, lambda: x >> 2) 564 | pytest.raises(TypeError, lambda: x << 1) 565 | pytest.raises(TypeError, lambda: x > 1) 566 | x == 1 # noqa: B015 567 | x ** Exception() 568 | 569 | 570 | def test_story_result_wrapper_bad_exception(): 571 | x = StoryResultWrapper(lambda *a: None) 572 | pytest.raises(RuntimeError, lambda: x**1) 573 | x**Exception 574 | x ** Exception('boom!') 575 | 576 | 577 | def test_story_create(): 578 | with Story(test_mod) as story: 579 | test_mod.target('a', 'b', 'c') == 'abc' # noqa: B015 580 | test_mod.target() ** Exception 581 | test_mod.target(1, 2, 3) == 'foobar' # noqa: B015 582 | obj = test_mod.Stuff('stuff') 583 | assert isinstance(obj, test_mod.Stuff) 584 | obj.meth('other', 1, 2) == 123 # noqa: B015 585 | obj.mix('other') == 'mixymix' # noqa: B015 586 | # from pprint import pprint as print 587 | # print (dict(story._calls)) 588 | assert dict(story._calls) == { 589 | (None, 'test_pkg1.test_pkg2.test_mod.Stuff', "'stuff'", ''): _Binds('stuff_1'), 590 | ('stuff_1', 'meth', "'other', 1, 2", ''): _Returns('123'), 591 | ('stuff_1', 'mix', "'other'", ''): _Returns("'mixymix'"), 592 | (None, 'test_pkg1.test_pkg2.test_mod.target', '', ''): _Raises('Exception'), 593 | (None, 'test_pkg1.test_pkg2.test_mod.target', '1, 2, 3', ''): _Returns("'foobar'"), 594 | (None, 'test_pkg1.test_pkg2.test_mod.target', "'a', 'b', 'c'", ''): _Returns("'abc'"), 595 | } 596 | 597 | 598 | def xtest_story_empty_play_proxy_class_dependencies(): 599 | with Story(test_mod).replay(recurse_lock=True, proxy=True, strict=False) as replay: 600 | obj = test_mod.Stuff(1, 2) 601 | other = obj.other('x') 602 | pytest.raises(ValueError, other.raises, 'badarg') # noqa: PT011 603 | other.mix(3, 4) 604 | obj = test_mod.Stuff(0, 1) 605 | obj.mix(3, 4) 606 | other = obj.other(2) 607 | other.mix(3, 4) 608 | 609 | print(repr(replay.diff)) 610 | 611 | assert replay.diff == '' 612 | -------------------------------------------------------------------------------- /tests/test_contrib.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | 3 | import pytest 4 | 5 | from aspectlib import contrib 6 | from aspectlib.contrib import retry 7 | from aspectlib.test import LogCapture 8 | 9 | 10 | def flaky_func(arg): 11 | if arg: 12 | arg.pop() 13 | raise OSError('Tough luck!') 14 | 15 | 16 | def test_done_suceess(): 17 | calls = [] 18 | 19 | @retry 20 | def ok_func(): 21 | calls.append(1) 22 | 23 | ok_func() 24 | assert calls == [1] 25 | 26 | 27 | def test_defaults(): 28 | calls = [] 29 | retry(sleep=calls.append)(flaky_func)([None] * 5) 30 | assert calls == [0, 0, 0, 0, 0] 31 | 32 | 33 | def test_raises(): 34 | calls = [] 35 | pytest.raises(OSError, retry(sleep=calls.append)(flaky_func), [None] * 6) # noqa: PT011 36 | assert calls == [0, 0, 0, 0, 0] 37 | 38 | calls = [] 39 | pytest.raises(OSError, retry(sleep=calls.append, retries=1)(flaky_func), [None, None]) # noqa: PT011 40 | assert calls == [0] 41 | 42 | 43 | def test_backoff(): 44 | calls = [] 45 | retry(sleep=calls.append, backoff=1.5)(flaky_func)([None] * 5) 46 | assert calls == [1.5, 1.5, 1.5, 1.5, 1.5] 47 | 48 | 49 | def test_backoff_exponential(): 50 | calls = [] 51 | retry(sleep=calls.append, retries=10, backoff=retry.exponential_backoff)(flaky_func)([None] * 10) 52 | print(calls) 53 | assert calls == [1, 2, 4, 8, 16, 32, 64, 128, 256, 512] 54 | 55 | 56 | def test_backoff_straight(): 57 | calls = [] 58 | retry(sleep=calls.append, retries=10, backoff=retry.straight_backoff)(flaky_func)([None] * 10) 59 | print(calls) 60 | assert calls == [1, 2, 5, 10, 15, 20, 25, 30, 35, 40] 61 | 62 | 63 | def test_backoff_flat(): 64 | calls = [] 65 | retry(sleep=calls.append, retries=10, backoff=retry.flat_backoff)(flaky_func)([None] * 10) 66 | print(calls) 67 | assert calls == [1, 2, 5, 10, 15, 30, 60, 60, 60, 60] 68 | 69 | 70 | def test_with_class(): 71 | logger = getLogger(__name__) 72 | 73 | class Connection: 74 | count = 0 75 | 76 | @retry 77 | def __init__(self, address): 78 | self.address = address 79 | self.__connect() 80 | 81 | def __connect(self, *_, **__): 82 | self.count += 1 83 | if self.count % 3: 84 | raise OSError('Failed') 85 | else: 86 | logger.info('connected!') 87 | 88 | @retry(cleanup=__connect) 89 | def action(self, arg1, arg2): 90 | self.count += 1 91 | if self.count % 3 == 0: 92 | raise OSError('Failed') 93 | else: 94 | logger.info('action!') 95 | 96 | def __repr__(self): 97 | return f'Connection@{self.count}' 98 | 99 | with LogCapture([logger, contrib.logger]) as logcap: 100 | try: 101 | conn = Connection('to-something') 102 | for i in range(5): 103 | conn.action(i, i) 104 | finally: 105 | for i in logcap.messages: 106 | print(i) 107 | assert logcap.messages == [ 108 | ('ERROR', "__init__((Connection@1, 'to-something'), {}) raised exception Failed. 5 retries left. Sleeping 0 secs."), 109 | ('ERROR', "__init__((Connection@1, 'to-something'), {}) raised exception Failed. 5 retries left. Sleeping 0 secs."), 110 | ('ERROR', "__init__((Connection@2, 'to-something'), {}) raised exception Failed. 4 retries left. Sleeping 0 secs."), 111 | ('ERROR', "__init__((Connection@2, 'to-something'), {}) raised exception Failed. 4 retries left. Sleeping 0 secs."), 112 | ('INFO', 'connected!'), 113 | ('INFO', 'action!'), 114 | ('INFO', 'action!'), 115 | ('ERROR', 'action((Connection@6, 2, 2), {}) raised exception Failed. 5 retries left. Sleeping 0 secs.'), 116 | ('ERROR', 'action((Connection@6, 2, 2), {}) raised exception Failed. 5 retries left. Sleeping 0 secs.'), 117 | ('ERROR', 'action((Connection@7, 2, 2), {}) raised exception Failed. 4 retries left. Sleeping 0 secs.'), 118 | ('ERROR', 'action((Connection@7, 2, 2), {}) raised exception Failed. 4 retries left. Sleeping 0 secs.'), 119 | ('ERROR', 'action((Connection@8, 2, 2), {}) raised exception Failed. 3 retries left. Sleeping 0 secs.'), 120 | ('ERROR', 'action((Connection@8, 2, 2), {}) raised exception Failed. 3 retries left. Sleeping 0 secs.'), 121 | ('INFO', 'connected!'), 122 | ('INFO', 'action!'), 123 | ('INFO', 'action!'), 124 | ('ERROR', 'action((Connection@12, 4, 4), {}) raised exception Failed. 5 retries left. Sleeping 0 secs.'), 125 | ('ERROR', 'action((Connection@12, 4, 4), {}) raised exception Failed. 5 retries left. Sleeping 0 secs.'), 126 | ('ERROR', 'action((Connection@13, 4, 4), {}) raised exception Failed. 4 retries left. Sleeping 0 secs.'), 127 | ('ERROR', 'action((Connection@13, 4, 4), {}) raised exception Failed. 4 retries left. Sleeping 0 secs.'), 128 | ('ERROR', 'action((Connection@14, 4, 4), {}) raised exception Failed. 3 retries left. Sleeping 0 secs.'), 129 | ('ERROR', 'action((Connection@14, 4, 4), {}) raised exception Failed. 3 retries left. Sleeping 0 secs.'), 130 | ('INFO', 'connected!'), 131 | ('INFO', 'action!'), 132 | ] 133 | -------------------------------------------------------------------------------- /tests/test_integrations.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import re 4 | import socket 5 | import warnings 6 | from datetime import timedelta 7 | 8 | import pytest 9 | from process_tests import dump_on_error 10 | from process_tests import wait_for_strings 11 | from tornado import gen 12 | from tornado import ioloop 13 | 14 | import aspectlib 15 | from aspectlib import debug 16 | from aspectlib.test import mock 17 | from aspectlib.test import record 18 | from aspectlib.utils import PYPY 19 | 20 | try: 21 | from StringIO import StringIO 22 | except ImportError: 23 | from io import StringIO 24 | 25 | LOG_TEST_SOCKET = r"""^\{_?socket(object)?\}.connect\(\('127.0.0.1', 1\)\) +<<< .*tests[\/]test_integrations.py:\d+:test_socket.* 26 | \{_?socket(object)?\}.connect \~ raised .*(ConnectionRefusedError|error)\((10061|111), .*refused.*\)""" 27 | 28 | 29 | def test_mock_builtin(): 30 | with aspectlib.weave(open, mock('foobar')): 31 | assert open('???') == 'foobar' # noqa: PTH123 32 | 33 | assert open(__file__) != 'foobar' # noqa: PTH123 34 | 35 | 36 | def test_mock_builtin_os(): 37 | print(os.open.__name__) 38 | with aspectlib.weave('os.open', mock('foobar')): 39 | assert os.open('???') == 'foobar' 40 | 41 | assert os.open(__file__, 0) != 'foobar' 42 | 43 | 44 | def test_record_warning(): 45 | with aspectlib.weave('warnings.warn', record): 46 | warnings.warn('crap', stacklevel=1) 47 | assert warnings.warn.calls == [(None, ('crap',), {'stacklevel': 1})] 48 | 49 | 50 | @pytest.mark.skipif(not hasattr(os, 'fork'), reason='os.fork not available') 51 | def test_fork(): 52 | with aspectlib.weave('os.fork', mock('foobar')): 53 | pid = os.fork() 54 | if not pid: 55 | os._exit(0) 56 | assert pid == 'foobar' 57 | 58 | pid = os.fork() 59 | if not pid: 60 | os._exit(0) 61 | assert pid != 'foobar' 62 | 63 | 64 | def test_socket(target=socket.socket): 65 | buf = StringIO() 66 | with aspectlib.weave(target, aspectlib.debug.log(print_to=buf, stacktrace=4, module=False), lazy=True): 67 | s = socket.socket() 68 | try: 69 | s.connect(('127.0.0.1', 1)) 70 | except Exception: # noqa: S110 71 | pass 72 | 73 | print(buf.getvalue()) 74 | assert re.match(LOG_TEST_SOCKET, buf.getvalue()) 75 | 76 | s = socket.socket() 77 | try: 78 | s.connect(('127.0.0.1', 1)) 79 | except Exception: # noqa: S110 80 | pass 81 | 82 | assert re.match(LOG_TEST_SOCKET, buf.getvalue()) 83 | 84 | 85 | def test_socket_as_string_target(): 86 | test_socket(target='socket.socket') 87 | 88 | 89 | def test_socket_meth(meth=socket.socket.close): 90 | calls = [] 91 | with aspectlib.weave(meth, record(calls=calls)): 92 | s = socket.socket() 93 | assert s.close() is None 94 | assert calls == [(s, (), {})] 95 | del calls[:] 96 | 97 | s = socket.socket() 98 | assert s.close() is None 99 | assert calls == [] 100 | 101 | 102 | def test_socket_meth_as_string_target(): 103 | test_socket_meth('socket.socket.close') 104 | 105 | 106 | def test_socket_all_methods(): 107 | buf = StringIO() 108 | with aspectlib.weave(socket.socket, aspectlib.debug.log(print_to=buf, stacktrace=False), lazy=True, methods=aspectlib.ALL_METHODS): 109 | socket.socket() 110 | 111 | assert '}.__init__ => None' in buf.getvalue() 112 | 113 | 114 | @pytest.mark.skipif(not hasattr(os, 'fork') or PYPY, reason='os.fork not available or PYPY') 115 | def test_realsocket_makefile(): 116 | buf = StringIO() 117 | p = socket.socket() 118 | p.bind(('127.0.0.1', 0)) 119 | p.listen(1) 120 | p.settimeout(1) 121 | pid = os.fork() 122 | 123 | if pid: 124 | with aspectlib.weave( 125 | ['socket.SocketIO', 'socket.socket'], 126 | aspectlib.debug.log(print_to=buf, stacktrace=False), 127 | lazy=True, 128 | methods=aspectlib.ALL_METHODS, 129 | ): 130 | s = socket.socket() 131 | s.settimeout(1) 132 | s.connect(p.getsockname()) 133 | fh = s.makefile('rwb', buffering=0) 134 | fh.write(b'STUFF\n') 135 | fh.readline() 136 | 137 | with dump_on_error(buf.getvalue): 138 | wait_for_strings( 139 | buf.getvalue, 140 | 0, 141 | '}.connect', 142 | '}.makefile', 143 | '}.write(', 144 | '}.send', 145 | '}.write =>', 146 | '}.readline()', 147 | '}.recv', 148 | '}.readline => ', 149 | ) 150 | else: 151 | try: 152 | c, _ = p.accept() 153 | c.settimeout(1) 154 | f = c.makefile('rw', buffering=1) 155 | while f.readline(): 156 | f.write('-\n') 157 | finally: 158 | os._exit(0) 159 | 160 | 161 | def test_weave_os_module(): 162 | calls = [] 163 | 164 | with aspectlib.weave('os', record(calls=calls, extended=True), methods='getenv|walk'): 165 | os.getenv('BUBU', 'bubu') 166 | os.walk('.') 167 | 168 | assert calls == [(None, 'os.getenv', ('BUBU', 'bubu'), {}), (None, 'os.walk', ('.',), {})] 169 | 170 | 171 | def test_decorate_asyncio_coroutine(): 172 | buf = StringIO() 173 | 174 | @debug.log(print_to=buf, module=False, stacktrace=2, result_repr=repr) 175 | async def coro(): 176 | await asyncio.sleep(0.01) 177 | return 'result' 178 | 179 | loop = asyncio.new_event_loop() 180 | loop.run_until_complete(coro()) 181 | output = buf.getvalue() 182 | print(output) 183 | assert 'coro => {!r}'.format('result') in output 184 | 185 | 186 | def test_decorate_tornado_coroutine(): 187 | buf = StringIO() 188 | 189 | @gen.coroutine 190 | @debug.log(print_to=buf, module=False, stacktrace=2, result_repr=repr) 191 | def coro(): 192 | if hasattr(gen, 'Task'): 193 | yield gen.Task(loop.add_timeout, timedelta(microseconds=10)) 194 | else: 195 | yield gen.sleep(0.01) 196 | return 'result' 197 | 198 | asyncio_loop = asyncio.new_event_loop() 199 | try: 200 | get_event_loop = asyncio.get_event_loop 201 | asyncio.get_event_loop = lambda: asyncio_loop 202 | loop = ioloop.IOLoop.current() 203 | loop.run_sync(coro) 204 | finally: 205 | asyncio.get_event_loop = get_event_loop 206 | output = buf.getvalue() 207 | assert 'coro => {!r}'.format('result') in output 208 | -------------------------------------------------------------------------------- /tests/test_pkg1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-aspectlib/52092d5e8ee86705c0cad9f9b510dcedabbdc46f/tests/test_pkg1/__init__.py -------------------------------------------------------------------------------- /tests/test_pkg1/test_pkg2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-aspectlib/52092d5e8ee86705c0cad9f9b510dcedabbdc46f/tests/test_pkg1/test_pkg2/__init__.py -------------------------------------------------------------------------------- /tests/test_pkg1/test_pkg2/test_mod.py: -------------------------------------------------------------------------------- 1 | def target(): 2 | return 3 | 4 | 5 | def func(*a): 6 | pass 7 | 8 | 9 | def raises(*a): 10 | raise ValueError(a) 11 | 12 | 13 | a = 1 14 | 15 | 16 | class Stuff: 17 | def __init__(self, *args): 18 | if args == ('error',): 19 | raise ValueError 20 | self.args = args 21 | 22 | def meth(self): 23 | pass 24 | 25 | def mix(self, *args): 26 | self.meth() 27 | return getattr(self, 'args', ()) + args 28 | 29 | def raises(self, *a): 30 | raise ValueError(a) 31 | 32 | def other(self, *args): 33 | return ThatLONGStuf(*self.args + args) 34 | 35 | 36 | class ThatLONGStuf(Stuff): 37 | pass 38 | -------------------------------------------------------------------------------- /tests/test_pytestsupport.py: -------------------------------------------------------------------------------- 1 | from aspectlib import test 2 | 3 | 4 | class Foo: 5 | def bar(self): 6 | return 1 7 | 8 | 9 | def test_fixture_1(weave): 10 | weave(Foo.bar, test.mock(2)) 11 | assert Foo().bar() == 2 12 | 13 | 14 | def test_fixture_2(weave): 15 | assert Foo().bar() == 1 16 | 17 | with weave(Foo.bar, test.mock(2)): 18 | assert Foo().bar() == 2 19 | 20 | assert Foo().bar() == 1 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv:bootstrap] 2 | deps = 3 | jinja2 4 | tox 5 | skip_install = true 6 | commands = 7 | python ci/bootstrap.py --no-env 8 | passenv = 9 | * 10 | 11 | ; a generative tox configuration, see: https://tox.wiki/en/latest/user_guide.html#generative-environments 12 | [tox] 13 | envlist = 14 | clean, 15 | check, 16 | docs, 17 | {py38,py39,py310,py311,py312,pypy37,pypy38,pypy39,pypy310}-{cover,nocov}-{release,debug}, 18 | report 19 | ignore_basepython_conflict = true 20 | 21 | [testenv] 22 | basepython = 23 | pypy38: {env:TOXPYTHON:pypy3.8} 24 | pypy39: {env:TOXPYTHON:pypy3.9} 25 | pypy310: {env:TOXPYTHON:pypy3.10} 26 | py38: {env:TOXPYTHON:python3.8} 27 | py39: {env:TOXPYTHON:python3.9} 28 | py310: {env:TOXPYTHON:python3.10} 29 | py311: {env:TOXPYTHON:python3.11} 30 | py312: {env:TOXPYTHON:python3.12} 31 | {bootstrap,clean,check,report,docs,codecov,coveralls}: {env:TOXPYTHON:python3} 32 | setenv = 33 | PYTHONPATH={toxinidir}/tests 34 | PYTHONUNBUFFERED=yes 35 | debug: ASPECTLIB_DEBUG=yes 36 | passenv = 37 | * 38 | usedevelop = 39 | cover: true 40 | nocov: false 41 | deps = 42 | hunter 43 | mock 44 | nose 45 | process-tests 46 | pytest 47 | six 48 | tornado 49 | cover: pytest-cov 50 | commands = 51 | nocov: {posargs:pytest -vv --ignore=src} 52 | cover: {posargs:pytest --cov --cov-report=term-missing --cov-report=xml -vv} 53 | 54 | [testenv:check] 55 | deps = 56 | docutils 57 | check-manifest 58 | pre-commit 59 | readme-renderer 60 | pygments 61 | isort 62 | skip_install = true 63 | commands = 64 | python setup.py check --strict --metadata --restructuredtext 65 | check-manifest . 66 | pre-commit run --all-files --show-diff-on-failure 67 | 68 | [testenv:docs] 69 | usedevelop = true 70 | deps = 71 | -r{toxinidir}/docs/requirements.txt 72 | commands = 73 | sphinx-build {posargs:-E} -b doctest docs dist/docs 74 | sphinx-build {posargs:-E} -b html docs dist/docs 75 | sphinx-build -b linkcheck docs dist/docs 76 | 77 | [testenv:report] 78 | deps = 79 | coverage 80 | skip_install = true 81 | commands = 82 | coverage report 83 | coverage html 84 | 85 | [testenv:clean] 86 | commands = 87 | python setup.py clean 88 | coverage erase 89 | skip_install = true 90 | deps = 91 | setuptools 92 | coverage 93 | --------------------------------------------------------------------------------