├── .bumpversion.cfg ├── .cookiecutterrc ├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── github-actions.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── .readthedocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── README.rst ├── build_backend └── backend.py ├── ci ├── bootstrap.py ├── requirements.txt └── templates │ └── .github │ └── workflows │ └── github-actions.yml ├── docs ├── authors.rst ├── changelog.rst ├── code-trace.png ├── conf.py ├── configuration.rst ├── contributing.rst ├── cookbook.rst ├── filtering.rst ├── index.rst ├── installation.rst ├── introduction.rst ├── readme.rst ├── reference.rst ├── remote.rst ├── requirements.txt ├── simple-trace.png ├── spelling_wordlist.txt ├── tree-trace.png └── vars-trace.png ├── pyproject.toml ├── pytest.ini ├── setup.py ├── src ├── hunter.pth └── hunter │ ├── __init__.py │ ├── _event.c │ ├── _event.pxd │ ├── _event.pyx │ ├── _predicates.c │ ├── _predicates.pxd │ ├── _predicates.pyx │ ├── _tracer.c │ ├── _tracer.pxd │ ├── _tracer.pyx │ ├── actions.py │ ├── config.py │ ├── const.py │ ├── event.py │ ├── predicates.py │ ├── remote.py │ ├── tracer.py │ ├── util.py │ └── vendor │ ├── __init__.py │ ├── _compat.h │ ├── _cymem │ ├── __init__.pxd │ ├── __init__.py │ ├── about.py │ ├── cymem.c │ ├── cymem.pxd │ └── cymem.pyx │ └── colorama │ ├── __init__.py │ ├── ansi.py │ ├── ansitowin32.py │ ├── initialise.py │ ├── win32.py │ └── winterm.py ├── tests ├── conftest.py ├── eviltracer.pxd ├── eviltracer.pyx ├── sample.py ├── sample2.py ├── sample3.py ├── sample4.py ├── sample5.pyx ├── sample6.py ├── sample7.py ├── sample7args.py ├── sample8errors.py ├── samplemanhole.py ├── samplepdb.py ├── setup.py ├── simple.py ├── test_config.py ├── test_cookbook.py ├── test_integration.py ├── test_predicates.py ├── test_remote.py ├── test_tracer.py ├── test_util.py └── utils.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 3.7.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = 'fallback_version': '{current_version}' 8 | replace = 'fallback_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}...master 16 | replace = /v{new_version}...master 17 | 18 | [bumpversion:file:docs/conf.py] 19 | search = version = release = '{current_version}' 20 | replace = version = release = '{new_version}' 21 | 22 | [bumpversion:file:src/hunter/__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: 'yes' 5 | c_extension_support: cython 6 | codacy: 'no' 7 | codacy_projectid: '-' 8 | codeclimate: 'no' 9 | codecov: 'yes' 10 | command_line_interface: argparse 11 | command_line_interface_bin_name: hunter-trace 12 | coveralls: 'no' 13 | distribution_name: hunter 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: 'yes' 20 | github_actions_windows: 'yes' 21 | license: BSD 2-Clause License 22 | module_name: core 23 | package_name: hunter 24 | pre_commit: 'yes' 25 | project_name: Hunter 26 | project_short_description: Hunter is a flexible code tracing toolkit, not for measuring coverage, but for debugging, logging, inspection and other nefarious purposes. It has a simple Python API and a convenient terminal API (see `Environment variable activation `_). 27 | pypi_badge: 'yes' 28 | pypi_disable_upload: 'no' 29 | release_date: '2023-04-26' 30 | repo_hosting: github.com 31 | repo_hosting_domain: github.com 32 | repo_main_branch: master 33 | repo_name: python-hunter 34 | repo_username: ionelmc 35 | scrutinizer: 'no' 36 | setup_py_uses_setuptools_scm: 'yes' 37 | sphinx_docs: 'yes' 38 | sphinx_docs_hosting: https://python-hunter.readthedocs.io/ 39 | sphinx_doctest: 'no' 40 | sphinx_theme: furo 41 | test_matrix_separate_coverage: 'yes' 42 | tests_inside_package: 'no' 43 | version: 3.7.0 44 | version_manager: bump2version 45 | website: https://blog.ionelmc.ro 46 | year_from: '2015' 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 | plugins = Cython.Coverage 11 | 12 | [report] 13 | show_missing = true 14 | precision = 2 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 | -------------------------------------------------------------------------------- /.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 | 76 | # Generated by setuptools-scm 77 | src/*/_version.py 78 | 79 | # test stuff 80 | tests/*.c 81 | 82 | # cython annotations 83 | src/*/*.html 84 | -------------------------------------------------------------------------------- /.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/|src/hunter.(pth|embed)$|src/hunter/vendor/|.bumpversion.cfg$|.*\.c$)' 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.4.2 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix, --show-fixes, --unsafe-fixes] 13 | - repo: https://github.com/psf/black 14 | rev: 24.4.2 15 | hooks: 16 | - id: black 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v4.6.0 19 | hooks: 20 | - id: trailing-whitespace 21 | exclude_types: 22 | - c 23 | - id: end-of-file-fixer 24 | exclude_types: 25 | - c 26 | - id: mixed-line-ending 27 | args: [--fix=lf] 28 | - id: debug-statements 29 | exclude: '^tests/sample*|src/hunter/actions.py' 30 | - repo: https://github.com/MarcoGorelli/cython-lint 31 | rev: v0.16.2 32 | hooks: 33 | - id: cython-lint 34 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | hunter 2 | -------------------------------------------------------------------------------- /.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 | * Claudiu Popa - https://github.com/PCManticore 7 | * Mikhail Borisov - https://github.com/borman 8 | * Dan Ailenei - https://github.com/Dan-Ailenei 9 | * Tom Schraitle - https://github.com/tomschr 10 | -------------------------------------------------------------------------------- /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 | Hunter could always use more documentation, whether as part of the 21 | official Hunter 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-hunter/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-hunter` for local development: 39 | 40 | 1. Fork `python-hunter `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/python-hunter.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) 2015-2024, Ionel Cristian Mărieș. All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 6 | following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 9 | disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 15 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 17 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 18 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 19 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 20 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 21 | -------------------------------------------------------------------------------- /build_backend/backend.py: -------------------------------------------------------------------------------- 1 | from setuptools import build_meta 2 | from setuptools.build_meta import * # noqa 3 | 4 | if hasattr(build_meta, 'build_editable'): 5 | del build_editable # noqa: F821 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>=20.4.7 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, cibw_arch, wheel_arch, include_cover in [ 34 | ['ubuntu', 'x64', 'x86_64', '*manylinux*', True], 35 | ['ubuntu', 'x64', 'x86_64', '*musllinux*', False], 36 | ['ubuntu', 'x64', 'aarch64', '*manylinux*', False], 37 | ['ubuntu', 'x64', 'aarch64', '*musllinux*', False], 38 | ['windows', 'x64', 'AMD64', '*', True], 39 | ['windows', 'x86', 'x86', '*', False], 40 | ['macos', 'arm64', 'arm64', '*', True], 41 | ] %} 42 | {% if include_cover or (env.endswith('cython-nocov') and not prefix.startswith('pypy')) %} 43 | {% set wheel_suffix = env.endswith('cython-nocov') and wheel_arch.strip('*') %} 44 | {% set name_suffix = '/' + wheel_suffix if wheel_suffix else '' %} 45 | - name: '{{ env }} ({{ os }}/{{ cibw_arch }}{{ name_suffix }})' 46 | python: '{{ python }}' 47 | toxpython: '{{ toxpython }}' 48 | python_arch: '{{ python_arch }}' 49 | tox_env: '{{ env }}' 50 | {% if 'cover' in env %} 51 | cover: true 52 | {% endif %} 53 | cibw_arch: '{{ cibw_arch }}' 54 | {% if env.endswith('cython-nocov') and not prefix.startswith('pypy') %} 55 | cibw_build: '{{ cpython }}-{{ wheel_arch }}' 56 | artifact: '{{ os }}-{{ cpython }}-{{ cibw_arch }}-{{ wheel_arch.strip("*") or "default" }}' 57 | {% else %} 58 | cibw_build: false 59 | {% endif %} 60 | os: '{{ os }}-latest' 61 | {% endif %} 62 | {% endfor %} 63 | {% endfor %} 64 | steps: 65 | - uses: docker/setup-qemu-action@v3 66 | if: matrix.cibw_arch == 'aarch64' 67 | with: 68 | platforms: arm64 69 | - uses: actions/checkout@v4 70 | with: 71 | fetch-depth: 0 72 | - uses: actions/setup-python@v5 73 | with: 74 | python-version: {{ '${{ matrix.python }}' }} 75 | architecture: {{ '${{ matrix.python_arch }}' }} 76 | - name: install dependencies 77 | run: | 78 | python -mpip install --progress-bar=off cibuildwheel -r ci/requirements.txt 79 | virtualenv --version 80 | pip --version 81 | tox --version 82 | pip list --format=freeze 83 | - name: install dependencies (gdb) 84 | if: > 85 | !matrix.cibw_build && matrix.os == 'ubuntu' 86 | run: > 87 | sudo apt-get install gdb 88 | - name: cibw build and test 89 | if: matrix.cibw_build 90 | run: cibuildwheel 91 | env: 92 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 93 | CIBW_ARCHS: '{{ '${{ matrix.cibw_arch }}' }}' 94 | CIBW_BUILD: '{{ '${{ matrix.cibw_build }}' }}' 95 | CIBW_BUILD_VERBOSITY: '3' 96 | CIBW_TEST_REQUIRES: > 97 | tox 98 | tox-direct 99 | CIBW_TEST_COMMAND: > 100 | cd {project} && 101 | tox --skip-pkg-install --direct-yolo -e {{ '${{ matrix.tox_env }}' }} -v 102 | CIBW_TEST_COMMAND_WINDOWS: > 103 | cd /d {project} && 104 | tox --skip-pkg-install --direct-yolo -e {{ '${{ matrix.tox_env }}' }} -v 105 | - name: regular build and test 106 | env: 107 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 108 | if: > 109 | !matrix.cibw_build 110 | run: > 111 | tox -e {{ '${{ matrix.tox_env }}' }} -v 112 | - uses: codecov/codecov-action@v3 113 | if: matrix.cover 114 | with: 115 | verbose: true 116 | flags: {{ '${{ matrix.tox_env }}' }} 117 | - name: check wheel 118 | if: matrix.cibw_build 119 | run: twine check wheelhouse/*.whl 120 | - name: upload wheel 121 | uses: actions/upload-artifact@v4 122 | if: matrix.cibw_build 123 | with: 124 | name: '{{ '${{ matrix.artifact }}' }}' 125 | if-no-files-found: error 126 | compression-level: 0 127 | path: wheelhouse/*.whl 128 | finish: 129 | needs: test 130 | if: {{ '${{ always() }}' }} 131 | runs-on: ubuntu-latest 132 | steps: 133 | - uses: codecov/codecov-action@v3 134 | with: 135 | CODECOV_TOKEN: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} 136 | {{ '' }} 137 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/code-trace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-hunter/5689bae52b2a8e32e7a4e509534486f136330a24/docs/code-trace.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ['PUREPYTHONHUNTER'] = 'yes' 4 | 5 | extensions = [ 6 | 'sphinx.ext.autodoc', 7 | 'sphinx.ext.autosectionlabel', 8 | 'sphinx.ext.autosummary', 9 | 'sphinx.ext.coverage', 10 | 'sphinx.ext.doctest', 11 | 'sphinx.ext.extlinks', 12 | 'sphinx.ext.ifconfig', 13 | 'sphinx.ext.napoleon', 14 | 'sphinx.ext.todo', 15 | 'sphinx.ext.viewcode', 16 | ] 17 | source_suffix = '.rst' 18 | master_doc = 'index' 19 | project = 'Hunter' 20 | year = '2015-2024' 21 | author = 'Ionel Cristian Mărieș' 22 | copyright = f'{year}, {author}' 23 | try: 24 | from pkg_resources import get_distribution 25 | 26 | version = release = get_distribution('hunter').version 27 | except Exception: 28 | import traceback 29 | 30 | traceback.print_exc() 31 | version = release = '3.7.0' 32 | 33 | pygments_style = 'trac' 34 | templates_path = ['.'] 35 | extlinks = { 36 | 'issue': ('https://github.com/ionelmc/python-hunter/issues/%s', '#%s'), 37 | 'pr': ('https://github.com/ionelmc/python-hunter/pull/%s', 'PR #%s'), 38 | } 39 | 40 | html_theme = 'furo' 41 | html_theme_options = { 42 | 'githuburl': 'https://github.com/ionelmc/python-hunter/', 43 | } 44 | 45 | html_use_smartypants = True 46 | html_last_updated_fmt = '%b %d, %Y' 47 | html_split_index = False 48 | html_short_title = f'{project}-{version}' 49 | 50 | napoleon_use_ivar = True 51 | napoleon_use_rtype = False 52 | napoleon_use_param = False 53 | 54 | autosummary_generate = True 55 | 56 | autosectionlabel_prefix_document = True 57 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Configuration 3 | ============= 4 | 5 | Default predicates and action kwargs defaults can be configured via a ``PYTHONHUNTERCONFIG`` environment variable. 6 | 7 | All the :ref:`actions ` kwargs: 8 | 9 | * ``klass`` 10 | * ``stream`` 11 | * ``force_colors`` 12 | * ``force_pid`` 13 | * ``filename_alignment`` 14 | * ``thread_alignment`` 15 | * ``pid_alignment`` 16 | * ``repr_limit`` 17 | * ``repr_func`` 18 | 19 | Example:: 20 | 21 | PYTHONHUNTERCONFIG="stdlib=False,force_colors=True" 22 | 23 | This is the same as ``PYTHONHUNTER="stdlib=False,action=CallPrinter(force_colors=True)"``. 24 | 25 | Notes: 26 | 27 | * Setting ``PYTHONHUNTERCONFIG`` alone doesn't activate hunter. 28 | * All the options for the builtin actions are supported. 29 | * Although using predicates is supported it can be problematic. Example of setup that won't trace anything:: 30 | 31 | PYTHONHUNTERCONFIG="Q(module_startswith='django')" 32 | PYTHONHUNTER="Q(module_startswith='celery')" 33 | 34 | which is the equivalent of:: 35 | 36 | PYTHONHUNTER="Q(module_startswith='django'),Q(module_startswith='celery')" 37 | 38 | which is the equivalent of:: 39 | 40 | PYTHONHUNTER="Q(module_startswith='django')&Q(module_startswith='celery')" 41 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/filtering.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Filtering 3 | ========= 4 | 5 | A list of all the keyword filters that :obj:`hunter.trace` or :obj:`hunter.Q` accept: 6 | 7 | * ``arg`` - you probably don't care about this - it may have a value for return/exception events 8 | * ``builtin`` (`bool`) - ``True`` if function is a builtin function 9 | * ``calls`` (`int`) - a call counter, you can use it to limit output by using a ``lt`` operator 10 | * ``depth`` (`int`) - call depth, starts from 0, increases for call events and decreases for returns 11 | * ``filename`` (`str`) 12 | * ``fullsource`` (`str`) - sourcecode for the executed lines (may be multiple lines in some situations) 13 | * ``function`` (`str`) - function name 14 | * ``globals`` (`dict`) - global variables 15 | * ``instruction`` (`int` or `str`, depending on Python version) - current executed bytecode, 16 | see :ref:`silenced-exception-runtime-analysis` for example usage 17 | * ``kind`` (`str`) - one of 'call', 'exception', 'line' or 'return' 18 | * ``lineno`` (`int`) 19 | * ``locals`` (`dict`) - local variables 20 | * ``module`` (`str`) - dotted module 21 | * ``source`` (`str`) - sourcecode for the executed line 22 | * ``stdlib`` (`bool`) - ``True`` if module is from stdlib 23 | * ``threadid`` (`int`) 24 | * ``threadname`` (`str`) - whatever `threading.Thread.name `_ 25 | returns 26 | 27 | You can append operators to the above filters. Note that some of of the filters won't work well with the `bool` or `int` types. 28 | 29 | * ``contains`` - works best with `str`, for 30 | example ``module_contains='foobar'`` translates to ``'foobar' in event.module`` 31 | * ``has`` - alias for ``contains`` 32 | * ``endswith`` - works best with `str`, for 33 | example ``module_endswith='foobar'`` translates to ``event.module.endswith('foobar')``. You can also pass in a iterable, 34 | example ``module_endswith=('foo', 'bar')`` is `acceptable `_ 35 | * ``ew`` - alias for ``endswith`` 36 | * ``gt`` - works best with `int`, for example ``lineno_gt=100`` translates to ``event.lineno > 100`` 37 | * ``gte`` - works best with `int`, for example ``lineno_gte=100`` translates to ``event.lineno >= 100`` 38 | * ``in`` - a membership test, for example ``module_in=('foo', 'bar')`` translates to ``event.module in ('foo', 'bar')``. You can use any 39 | iterable, for example ``module_in='foo bar'`` translates to ``event.module in 'foo bar'``, and that would probably have the same result 40 | as the first example 41 | * ``lt`` - works best with `int`, for example ``calls_lt=100`` translates to ``event.calls < 100`` 42 | * ``lte`` - works best with `int`, for example ``depth_lte=100`` translates to ``event.depth <= 100`` 43 | * ``regex`` - works best with `str`, for 44 | example ``module_regex=r'(test|test.*)\b'`` translates to ``re.match(r'(test|test.*)\b', event.module)`` 45 | * ``rx`` - alias for ``regex`` 46 | * ``startswith`` - works best with `str`, for 47 | example ``module_startswith='foobar'`` translates to ``event.module.startswith('foobar')``. You can also pass in a iterable, 48 | example ``module_startswith=('foo', 'bar')`` is `acceptable `_ 49 | * ``sw`` - alias for ``startswith`` 50 | 51 | Notes: 52 | 53 | * you can also use double underscore (if you're too used to Django query lookups), eg: ``module__has='foobar'`` is acceptable 54 | * there's nothing smart going on for the dots in module names so sometimes you might need to account for said dots: 55 | 56 | * ``module_sw='foo'`` will match ``"foo.bar"`` and ``"foobar"`` - if you want to avoid matchin the later you could do either of: 57 | 58 | * ``Q(module='foo')|Q(module_sw='foo.')`` 59 | * ``Q(module_rx=r'foo($|\.)')`` - but this might cost you in speed 60 | * ``Q(filename_sw='/path/to/foo/')`` - probably the fastest 61 | * ``Q(filename_has='/foo/')`` - avoids putting in the full path but might match unwanted paths 62 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contents 3 | ======== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | readme 9 | installation 10 | introduction 11 | remote 12 | configuration 13 | filtering 14 | cookbook 15 | reference 16 | contributing 17 | authors 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 hunter 8 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Introduction 3 | ============ 4 | 5 | Installation 6 | ============ 7 | 8 | To install hunter run:: 9 | 10 | pip install hunter 11 | 12 | 13 | The ``trace`` function 14 | ====================== 15 | 16 | The :obj:`hunter.trace` function can take 2 types of arguments: 17 | 18 | * Keyword arguments like ``module``, ``function`` or ``action`` (see :obj:`hunter.Event` for all the possible 19 | filters). 20 | * Callbacks that take an ``event`` argument: 21 | 22 | * Builtin predicates like: :class:`hunter.predicates.Query`, :class:`hunter.When`, :class:`hunter.And` or :class:`hunter.Or`. 23 | * Actions like: :class:`hunter.actions.CodePrinter`, :class:`hunter.actions.Debugger` or :class:`hunter.actions.VarsPrinter` 24 | * Any function. Or a disgusting lambda. 25 | 26 | Note that :obj:`hunter.trace` will use :obj:`hunter.Q` when you pass multiple positional arguments or keyword arguments. 27 | 28 | The ``Q`` function 29 | ================== 30 | 31 | The :func:`hunter.Q` function provides a convenience API for you: 32 | 33 | * ``Q(module='foobar')`` is converted to ``Query(module='foobar')``. 34 | * ``Q(module='foobar', action=Debugger)`` is converted to ``When(Query(module='foobar'), Debugger)``. 35 | * ``Q(module='foobar', actions=[CodePrinter, VarsPrinter('name')])`` is converted to 36 | ``When(Query(module='foobar'), CodePrinter, VarsPrinter('name'))``. 37 | * ``Q(Q(module='foo'), Q(module='bar'))`` is converted to ``And(Q(module='foo'), Q(module='bar'))``. 38 | * ``Q(your_own_callback, module='foo')`` is converted to ``And(your_own_callback, Q(module='foo'))``. 39 | 40 | Note that the default junction :func:`hunter.Q` uses is :class:`hunter.predicates.And`. 41 | 42 | Composing 43 | ========= 44 | 45 | All the builtin predicates (:class:`hunter.predicates.Query`, :class:`hunter.predicates.When`, 46 | :class:`hunter.predicates.And`, :class:`hunter.predicates.Not` and :class:`hunter.predicates.Or`) support 47 | the ``|``, ``&`` and ``~`` operators: 48 | 49 | * ``Query(module='foo') | Query(module='bar')`` is converted to ``Or(Query(module='foo'), Query(module='bar'))`` 50 | * ``Query(module='foo') & Query(module='bar')`` is converted to ``And(Query(module='foo'), Query(module='bar'))`` 51 | * ``~Query(module='foo')`` is converted to ``Not(Query(module='foo'))`` 52 | 53 | Operators 54 | ========= 55 | 56 | .. versionadded:: 1.0.0 57 | 58 | You can add ``startswith``, ``endswith``, ``in``, ``contains``, ``regex``, ``lt``, ``lte``, ``gt``, ``gte`` to your 59 | keyword arguments, just like in Django. Double underscores are not necessary, but in case you got twitchy fingers 60 | it'll just work - ``filename__startswith`` is the same as ``filename_startswith``. 61 | 62 | .. versionadded:: 2.0.0 63 | 64 | You can also use these convenience aliases: ``sw`` (``startswith``), ``ew`` (``endswith``), ``rx`` (``regex``) and 65 | ``has`` (``contains``). 66 | 67 | Examples: 68 | 69 | * ``Query(module_in=['re', 'sre', 'sre_parse'])`` will match events from any of those modules. 70 | * ``~Query(module_in=['re', 'sre', 'sre_parse'])`` will match events from any modules except those. 71 | * ``Query(module_startswith=['re', 'sre', 'sre_parse'])`` will match any events from modules that starts with either of 72 | those. That means ``repr`` will match! 73 | * ``Query(module_regex='(re|sre.*)$')`` will match any events from ``re`` or anything that starts with ``sre``. 74 | 75 | .. note:: If you want to filter out stdlib stuff you're better off with using ``Query(stdlib=False)``. 76 | 77 | Activation 78 | ========== 79 | 80 | You can activate Hunter in three ways. 81 | 82 | from code 83 | --------- 84 | 85 | .. sourcecode:: python 86 | 87 | import hunter 88 | hunter.trace( 89 | ... 90 | ) 91 | 92 | with an environment variable 93 | ---------------------------- 94 | 95 | Set the ``PYTHONHUNTER`` environment variable. Eg: 96 | 97 | .. sourcecode:: bash 98 | 99 | PYTHONHUNTER="module='os.path'" python yourapp.py 100 | 101 | On Windows you'd do something like: 102 | 103 | .. sourcecode:: bat 104 | 105 | set PYTHONHUNTER=module='os.path' 106 | python yourapp.py 107 | 108 | The activation works with a clever ``.pth`` file that checks for that env var presence and before your app runs does something like this: 109 | 110 | .. sourcecode:: python 111 | 112 | from hunter import * 113 | trace( 114 | 115 | ) 116 | 117 | That also means that it will do activation even if the env var is empty, eg: ``PYTHONHUNTER=""``. 118 | 119 | with a CLI tool 120 | --------------- 121 | 122 | If you got an already running process you can attach to it with ``hunter-trace``. See :doc:`remote` for details. 123 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. _helpers-summary: 5 | 6 | .. highlights:: :ref:`reference:Helpers` 7 | 8 | .. autosummary:: 9 | 10 | hunter.trace 11 | hunter.stop 12 | hunter.wrap 13 | hunter.And 14 | hunter.Backlog 15 | hunter.From 16 | hunter.Not 17 | hunter.Or 18 | hunter.Q 19 | 20 | .. highlights:: :ref:`reference:Actions` 21 | 22 | .. autosummary:: 23 | 24 | hunter.actions.CallPrinter 25 | hunter.actions.CodePrinter 26 | hunter.actions.ColorStreamAction 27 | hunter.actions.Debugger 28 | hunter.actions.ErrorSnooper 29 | hunter.actions.Manhole 30 | hunter.actions.StackPrinter 31 | hunter.actions.VarsPrinter 32 | hunter.actions.VarsSnooper 33 | 34 | .. warning:: 35 | 36 | The following (Predicates and Internals) have Cython implementations in modules prefixed with "_". 37 | They should be imported from the ``hunter`` module, not ``hunter.something`` to be sure you get the best available implementation. 38 | 39 | .. highlights:: :ref:`reference:Predicates` 40 | 41 | .. autosummary:: 42 | 43 | hunter.predicates.And 44 | hunter.predicates.Backlog 45 | hunter.predicates.From 46 | hunter.predicates.Not 47 | hunter.predicates.Or 48 | hunter.predicates.Query 49 | hunter.predicates.When 50 | 51 | .. highlights:: :ref:`reference:Internals` 52 | 53 | .. autosummary:: 54 | 55 | hunter.event.Event 56 | hunter.tracer.Tracer 57 | 58 | | 59 | | 60 | | 61 | 62 | ---- 63 | 64 | Helpers 65 | ------- 66 | 67 | .. autofunction:: hunter.trace(*predicates, clear_env_var=False, action=CodePrinter, actions=[], **kwargs) 68 | 69 | .. autofunction:: hunter.stop() 70 | 71 | .. autofunction:: hunter.wrap 72 | 73 | .. autofunction:: hunter.And 74 | 75 | .. autofunction:: hunter.Backlog 76 | 77 | .. autofunction:: hunter.From 78 | 79 | .. autofunction:: hunter.Not 80 | 81 | .. autofunction:: hunter.Or 82 | 83 | .. autofunction:: hunter.Q 84 | 85 | 86 | ---- 87 | 88 | Actions 89 | ------- 90 | 91 | .. autoclass:: hunter.actions.CallPrinter(stream=sys.stderr, force_colors=False, force_pid=False, filename_alignment=40, thread_alignment=12, pid_alignment=9, repr_limit=1024, repr_func='safe_repr') 92 | :members: 93 | :special-members: 94 | 95 | .. autoclass:: hunter.actions.CodePrinter(stream=sys.stderr, force_colors=False, force_pid=False, filename_alignment=40, thread_alignment=12, pid_alignment=9, repr_limit=1024, repr_func='safe_repr') 96 | :members: 97 | :special-members: 98 | 99 | .. autoclass:: hunter.actions.ColorStreamAction(stream=sys.stderr, force_colors=False, force_pid=False, filename_alignment=40, thread_alignment=12, pid_alignment=9, repr_limit=1024, repr_func='safe_repr') 100 | :members: 101 | :special-members: 102 | 103 | .. autoclass:: hunter.actions.Debugger(klass=pdb.Pdb, **kwargs) 104 | :members: 105 | :special-members: 106 | 107 | .. autoclass:: hunter.actions.ErrorSnooper(max_events=50, max_depth=1, stream=sys.stderr, force_colors=False, force_pid=False, filename_alignment=40, thread_alignment=12, pid_alignment=9, repr_limit=1024, repr_func='safe_repr') 108 | :members: 109 | :special-members: 110 | 111 | .. autoclass:: hunter.actions.Manhole(**options) 112 | :members: 113 | :special-members: 114 | 115 | .. autoclass:: hunter.actions.StackPrinter(depth=15, limit=2, stream=sys.stderr, force_colors=False, force_pid=False, filename_alignment=40, thread_alignment=12, pid_alignment=9, repr_limit=1024, repr_func='safe_repr') 116 | :members: 117 | :special-members: 118 | 119 | .. autoclass:: hunter.actions.VarsPrinter(name, [name, [name, [...]]], stream=sys.stderr, force_colors=False, force_pid=False, filename_alignment=40, thread_alignment=12, pid_alignment=9, repr_limit=1024, repr_func='safe_repr') 120 | :members: 121 | :special-members: 122 | 123 | .. autoclass:: hunter.actions.VarsSnooper(stream=sys.stderr, force_colors=False, force_pid=False, filename_alignment=40, thread_alignment=12, pid_alignment=9, repr_limit=1024, repr_func='safe_repr') 124 | :members: 125 | :special-members: 126 | 127 | ---- 128 | 129 | Predicates 130 | ---------- 131 | 132 | .. warning:: 133 | 134 | These have Cython implementations in modules prefixed with "_". 135 | 136 | Note that: 137 | 138 | * Every predicate except :class:`~hunter.predicates.When` has a :ref:`helper ` importable directly from the 139 | ``hunter`` package. 140 | * Ideally you'd use the helpers instead of these to get the best available implementation, extra validation and 141 | better argument handling. 142 | 143 | .. autoclass:: hunter.predicates.And 144 | :members: 145 | :special-members: 146 | 147 | .. autoclass:: hunter.predicates.Backlog 148 | :members: 149 | :special-members: 150 | 151 | .. autoclass:: hunter.predicates.From 152 | :members: 153 | :special-members: 154 | 155 | .. autoclass:: hunter.predicates.Not 156 | :members: 157 | :special-members: 158 | 159 | .. autoclass:: hunter.predicates.Or 160 | :members: 161 | :special-members: 162 | 163 | .. autoclass:: hunter.predicates.Query 164 | :members: 165 | :special-members: 166 | 167 | .. autoclass:: hunter.predicates.When 168 | :members: 169 | :special-members: 170 | 171 | ---- 172 | 173 | Internals 174 | --------- 175 | 176 | .. warning:: 177 | 178 | These have Cython implementations in modules prefixed with "_". 179 | They should be imported from the ``hunter`` module, not ``hunter.something`` to be sure you get the best available implementation. 180 | 181 | Normally these are not used directly. Perhaps just the :class:`~hunter.tracer.Tracer` may be used directly for 182 | performance reasons. 183 | 184 | .. autoclass:: hunter.event.Event 185 | :members: 186 | :special-members: 187 | 188 | .. autoclass:: hunter.tracer.Tracer 189 | :members: 190 | :special-members: 191 | -------------------------------------------------------------------------------- /docs/remote.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Remote tracing 3 | ============== 4 | 5 | Hunter supports tracing local processes, with two backends: `manhole `_ and GDB. 6 | For now Windows isn't supported. 7 | 8 | Using GDB is risky (if anything goes wrong your process will probably be hosed up badly) so the Manhole backend is 9 | recommended. To use it: 10 | 11 | .. sourcecode:: python 12 | 13 | from hunter import remote 14 | remote.install() 15 | 16 | You should put this somewhere where it's run early in your project (settings or package's ``__init__.py`` file). 17 | 18 | The ``remote.install()`` takes same arguments as ``manhole.install()``. You'll probably only want to use ``verbose=False`` ... 19 | 20 | 21 | The CLI 22 | ======= 23 | 24 | :: 25 | 26 | usage: hunter-trace [-h] -p PID [-t TIMEOUT] [--gdb] [-s SIGNAL] 27 | [OPTIONS [OPTIONS ...]] 28 | 29 | 30 | 31 | positional arguments: 32 | OPTIONS 33 | 34 | optional arguments: 35 | -h, --help show this help message and exit 36 | -p PID, --pid PID A numerical process id. 37 | -t TIMEOUT, --timeout TIMEOUT 38 | Timeout to use. Default: 1 seconds. 39 | --gdb Use GDB to activate tracing. WARNING: it may deadlock 40 | the process! 41 | -s SIGNAL, --signal SIGNAL 42 | Send the given SIGNAL to the process before 43 | connecting. 44 | 45 | The ``OPTIONS`` are ``hunter.trace()`` arguments. 46 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.3 2 | furo 3 | -------------------------------------------------------------------------------- /docs/simple-trace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-hunter/5689bae52b2a8e32e7a4e509534486f136330a24/docs/simple-trace.png -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | builtins 3 | classmethod 4 | staticmethod 5 | classmethods 6 | staticmethods 7 | args 8 | kwargs 9 | callstack 10 | Changelog 11 | Indices 12 | -------------------------------------------------------------------------------- /docs/tree-trace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-hunter/5689bae52b2a8e32e7a4e509534486f136330a24/docs/tree-trace.png -------------------------------------------------------------------------------- /docs/vars-trace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-hunter/5689bae52b2a8e32e7a4e509534486f136330a24/docs/vars-trace.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=64.0.0", 4 | "wheel", 5 | "setuptools_scm>=3.3.1,!=4.0.0", 6 | ] 7 | build-backend = "backend" 8 | backend-path = ["build_backend"] 9 | 10 | [tool.ruff] 11 | extend-exclude = ["static", "ci/templates"] 12 | line-length = 140 13 | src = ["src", "tests"] 14 | target-version = "py38" 15 | [tool.cython-lint] 16 | max-line-length = 140 17 | 18 | [tool.ruff.lint.per-file-ignores] 19 | "ci/*" = ["S"] 20 | "tests/*" = ["ALL"] 21 | 22 | [tool.ruff.lint] 23 | ignore = [ 24 | "RUF001", # ruff-specific rules ambiguous-unicode-character-string 25 | "S101", # flake8-bandit assert 26 | "S307", 27 | "S308", # flake8-bandit suspicious-mark-safe-usage 28 | "E501", # pycodestyle line-too-long 29 | ] 30 | select = [ 31 | "B", # flake8-bugbear 32 | "C4", # flake8-comprehensions 33 | "DTZ", # flake8-datetimez 34 | "E", # pycodestyle errors 35 | "EXE", # flake8-executable 36 | "F", # pyflakes 37 | "I", # isort 38 | "INT", # flake8-gettext 39 | "PIE", # flake8-pie 40 | "PLC", # pylint convention 41 | "PLE", # pylint errors 42 | "PT", # flake8-pytest-style 43 | "Q", # flake8-quotes 44 | "RSE", # flake8-raise 45 | "RUF", # ruff-specific rules 46 | "S", # flake8-bandit 47 | "UP", # pyupgrade 48 | "W", # pycodestyle warnings 49 | ] 50 | 51 | [tool.ruff.lint.flake8-pytest-style] 52 | fixture-parentheses = false 53 | mark-parentheses = false 54 | 55 | [tool.ruff.lint.flake8-quotes] 56 | inline-quotes = "single" 57 | 58 | [tool.ruff.lint.isort] 59 | extra-standard-library = ["opcode"] 60 | forced-separate = ["conftest"] 61 | force-single-line = true 62 | 63 | [tool.black] 64 | line-length = 140 65 | target-version = ["py38"] 66 | skip-string-normalization = true 67 | -------------------------------------------------------------------------------- /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 | --tb=short 26 | testpaths = 27 | tests 28 | 29 | # Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors 30 | filterwarnings = 31 | error 32 | ignore:Plugin file tracers .Cython.Coverage.Plugin. aren::: 33 | # You can add exclusions, some examples: 34 | # ignore:'hunter' defines default_app_config:PendingDeprecationWarning:: 35 | # ignore:The {{% if::: 36 | # ignore:Coverage disabled via --no-cov switch! 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import re 4 | import sys 5 | from itertools import chain 6 | from pathlib import Path 7 | 8 | from setuptools import Extension 9 | from setuptools import find_packages 10 | from setuptools import setup 11 | from setuptools.command.build import build 12 | from setuptools.command.build_ext import build_ext 13 | from setuptools.command.develop import develop 14 | from setuptools.command.easy_install import easy_install 15 | from setuptools.command.install_lib import install_lib 16 | from setuptools.dist import Distribution 17 | 18 | try: 19 | # Allow installing package without any Cython available. This 20 | # assumes you are going to include the .c files in your sdist. 21 | import Cython 22 | except ImportError: 23 | Cython = None 24 | 25 | # Enable code coverage for C code: we cannot use CFLAGS=-coverage in tox.ini, since that may mess with compiling 26 | # dependencies (e.g. numpy). Therefore we set SETUPPY_CFLAGS=-coverage in tox.ini and copy it to CFLAGS here (after 27 | # deps have been safely installed). 28 | if 'TOX_ENV_NAME' in os.environ and os.environ.get('SETUPPY_EXT_COVERAGE') == 'yes': 29 | CFLAGS = os.environ['CFLAGS'] = '-DCYTHON_TRACE=1' 30 | LFLAGS = os.environ['LFLAGS'] = '' 31 | else: 32 | CFLAGS = '' 33 | LFLAGS = '' 34 | pth_file = str(Path(__file__).parent.joinpath('src', 'hunter.pth')) 35 | 36 | 37 | class BuildWithPTH(build): 38 | def run(self): 39 | super().run() 40 | self.copy_file(pth_file, str(Path(self.build_lib, 'hunter.pth'))) 41 | 42 | 43 | class EasyInstallWithPTH(easy_install): 44 | def run(self, *args, **kwargs): 45 | super().run(*args, **kwargs) 46 | self.copy_file(pth_file, str(Path(self.install_dir, 'hunter.pth'))) 47 | 48 | 49 | class InstallLibWithPTH(install_lib): 50 | def run(self): 51 | super().run() 52 | dest = str(Path(self.install_dir, 'hunter.pth')) 53 | self.copy_file(pth_file, dest) 54 | self.outputs = [dest] 55 | 56 | def get_outputs(self): 57 | return chain(install_lib.get_outputs(self), self.outputs) 58 | 59 | 60 | class DevelopWithPTH(develop): 61 | def run(self): 62 | super().run() 63 | self.copy_file(pth_file, str(Path(self.install_dir, 'hunter.pth'))) 64 | 65 | 66 | class OptionalBuildExt(build_ext): 67 | """ 68 | Allow the building of C extensions to fail. 69 | """ 70 | 71 | def run(self): 72 | try: 73 | if os.environ.get('SETUPPY_FORCE_PURE'): 74 | raise Exception('C extensions disabled (SETUPPY_FORCE_PURE)!') 75 | super().run() 76 | except Exception as e: 77 | self._unavailable(e) 78 | self.extensions = [] # avoid copying missing files (it would fail). 79 | 80 | def _unavailable(self, e): 81 | print('*' * 80) 82 | print( 83 | """WARNING: 84 | 85 | An optional code optimization (C extension) could not be compiled. 86 | 87 | Optimizations for this package will not be available! 88 | """ 89 | ) 90 | 91 | print('CAUSE:') 92 | print('') 93 | print(' ' + repr(e)) 94 | print('*' * 80) 95 | 96 | 97 | class BinaryDistribution(Distribution): 98 | """ 99 | Distribution which almost always forces a binary package with platform name 100 | """ 101 | 102 | def has_ext_modules(self): 103 | return super().has_ext_modules() or not os.environ.get('SETUPPY_ALLOW_PURE') 104 | 105 | 106 | def read(*names, **kwargs): 107 | with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get('encoding', 'utf8')) as fh: 108 | return fh.read() 109 | 110 | 111 | setup( 112 | name='hunter', 113 | use_scm_version={ 114 | 'local_scheme': 'dirty-tag', 115 | 'write_to': 'src/hunter/_version.py', 116 | 'fallback_version': '3.7.0', 117 | }, 118 | license='BSD-2-Clause', 119 | description='Hunter is a flexible code tracing toolkit.', 120 | long_description='{}\n{}'.format( 121 | re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')), 122 | re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')), 123 | ), 124 | author='Ionel Cristian Mărieș', 125 | author_email='contact@ionelmc.ro', 126 | url='https://github.com/ionelmc/python-hunter', 127 | packages=find_packages('src'), 128 | package_dir={'': 'src'}, 129 | py_modules=[path.stem for path in Path('src').glob('*.py')], 130 | zip_safe=False, 131 | classifiers=[ 132 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 133 | 'Development Status :: 5 - Production/Stable', 134 | 'Intended Audience :: Developers', 135 | 'License :: OSI Approved :: BSD License', 136 | 'Operating System :: Unix', 137 | 'Operating System :: POSIX', 138 | 'Operating System :: Microsoft :: Windows', 139 | 'Programming Language :: Python', 140 | 'Programming Language :: Python :: 3', 141 | 'Programming Language :: Python :: 3 :: Only', 142 | 'Programming Language :: Python :: 3.8', 143 | 'Programming Language :: Python :: 3.9', 144 | 'Programming Language :: Python :: 3.10', 145 | 'Programming Language :: Python :: 3.11', 146 | 'Programming Language :: Python :: 3.12', 147 | 'Programming Language :: Python :: Implementation :: CPython', 148 | 'Programming Language :: Python :: Implementation :: PyPy', 149 | 'Topic :: Utilities', 150 | 'Topic :: Software Development :: Debuggers', 151 | ], 152 | project_urls={ 153 | 'Documentation': 'https://python-hunter.readthedocs.io/', 154 | 'Changelog': 'https://python-hunter.readthedocs.io/en/latest/changelog.html', 155 | 'Issue Tracker': 'https://github.com/ionelmc/python-hunter/issues', 156 | }, 157 | keywords=[ 158 | 'trace', 159 | 'tracer', 160 | 'settrace', 161 | 'debugger', 162 | 'debugging', 163 | 'code', 164 | 'source', 165 | ], 166 | python_requires='>=3.8', 167 | install_requires=[], 168 | extras_require={ 169 | ':platform_system != "Windows"': ['manhole >= 1.5'], 170 | }, 171 | setup_requires=( 172 | [ 173 | 'setuptools_scm>=3.3.1,!=4.0.0', 174 | 'cython', 175 | ] 176 | if Cython 177 | else [ 178 | 'setuptools_scm>=3.3.1,!=4.0.0', 179 | ] 180 | ), 181 | entry_points={ 182 | 'console_scripts': [ 183 | 'hunter-trace = hunter.remote:main', 184 | ] 185 | }, 186 | cmdclass={ 187 | 'build': BuildWithPTH, 188 | 'easy_install': EasyInstallWithPTH, 189 | 'install_lib': InstallLibWithPTH, 190 | 'develop': DevelopWithPTH, 191 | 'build_ext': OptionalBuildExt, 192 | }, 193 | ext_modules=( 194 | [] 195 | if hasattr(sys, 'pypy_version_info') 196 | else [ 197 | Extension( 198 | str(path.relative_to('src').with_suffix('')).replace(os.sep, '.'), 199 | sources=[str(path)], 200 | extra_compile_args=CFLAGS.split(), 201 | extra_link_args=LFLAGS.split(), 202 | include_dirs=[str(path.parent)], 203 | ) 204 | for path in Path('src').glob('**/*.pyx' if Cython else '**/*.c') 205 | ] 206 | ), 207 | distclass=BinaryDistribution, 208 | ) 209 | -------------------------------------------------------------------------------- /src/hunter.pth: -------------------------------------------------------------------------------- 1 | import hunter; hunter._embed_via_environment() 2 | -------------------------------------------------------------------------------- /src/hunter/_event.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3str 2 | cimport cython 3 | 4 | from ._tracer cimport * 5 | 6 | 7 | cdef extern from *: 8 | void PyFrame_FastToLocals(FrameType) 9 | int PyFrame_GetLineNumber(FrameType) 10 | 11 | 12 | @cython.final 13 | cdef class Event: 14 | cdef: 15 | readonly FrameType frame 16 | readonly str kind 17 | readonly object arg 18 | readonly int depth 19 | readonly int calls 20 | readonly bint threading_support 21 | readonly bint detached 22 | readonly object builtin 23 | 24 | object _code 25 | object _filename 26 | object _fullsource 27 | object _function 28 | object _function_object 29 | object _globals 30 | object _lineno 31 | object _locals 32 | object _module 33 | object _source 34 | object _stdlib 35 | object _thread 36 | object _threadidn # slightly different name cause "_threadid" is a goddamn macro in Microsoft stddef.h 37 | object _threadname 38 | object _instruction 39 | 40 | CodeType code_getter(self) 41 | object filename_getter(self) 42 | object fullsource_getter(self) 43 | object function_getter(self) 44 | object globals_getter(self) 45 | object lineno_getter(self) 46 | object locals_getter(self) 47 | object module_getter(self) 48 | object source_getter(self) 49 | object stdlib_getter(self) 50 | object threadid_getter(self) 51 | object threadname_getter(self) 52 | object instruction_getter(self) 53 | 54 | cdef Event fast_clone(Event self) 55 | cdef Event fast_detach(Event self, object value_filter) 56 | -------------------------------------------------------------------------------- /src/hunter/_event.pyx: -------------------------------------------------------------------------------- 1 | # cython: linetrace=True, language_level=3str, c_string_encoding=ascii 2 | from functools import partial 3 | from linecache import getline 4 | from linecache import getlines 5 | from os.path import basename 6 | from os.path import exists 7 | from os.path import splitext 8 | from threading import current_thread 9 | from tokenize import TokenError 10 | from tokenize import generate_tokens 11 | 12 | from cpython.pythread cimport PyThread_get_thread_ident 13 | from cpython.ref cimport Py_XINCREF 14 | from cpython.ref cimport PyObject 15 | from cython cimport auto_pickle 16 | 17 | from ._tracer cimport Tracer 18 | from .vendor._cymem.cymem cimport Pool 19 | 20 | from .const import SITE_PACKAGES_PATHS 21 | from .const import SYS_PREFIX_PATHS 22 | from .util import CYTHON_SUFFIX_RE 23 | from .util import LEADING_WHITESPACE_RE 24 | from .util import MISSING 25 | from .util import get_func_in_mro 26 | from .util import get_main_thread 27 | from .util import if_same_code 28 | 29 | __all__ = 'Event', 30 | 31 | cdef object UNSET = object() 32 | 33 | cdef Pool mem = Pool() 34 | cdef PyObject** KIND_NAMES = make_kind_names(['call', 'exception', 'line', 'return', 'call', 'exception', 'return']) 35 | 36 | cdef inline PyObject** make_kind_names(list strings): 37 | cdef PyObject** array = mem.alloc(len(strings), sizeof(PyObject*)) 38 | cdef object name 39 | for i, string in enumerate(strings): 40 | name = intern(string) 41 | Py_XINCREF( name) 42 | array[i] = name 43 | return array 44 | 45 | 46 | @auto_pickle(False) 47 | cdef class Event: 48 | """ 49 | A wrapper object for Frame objects. Instances of this are passed to your custom functions or predicates. 50 | 51 | Provides few convenience properties. 52 | 53 | Args: 54 | frame (Frame): A python `Frame `_ object. 55 | kind (str): A string like ``'call'``, ``'line'``, ``'return'`` or ``'exception'``. 56 | arg: A value that depends on ``kind``. Usually is ``None`` but for ``'return'`` or ``'exception'`` other values 57 | may be expected. 58 | tracer (:class:`hunter.tracer.Tracer`): The :class:`~hunter.tracer.Tracer` instance that created the event. 59 | Needed for the ``calls`` and ``depth`` fields. 60 | """ 61 | def __init__(self, FrameType frame, int kind, object arg, Tracer tracer=None, object depth=None, object calls=None, 62 | object threading_support=MISSING): 63 | if tracer is None: 64 | if depth is None: 65 | raise TypeError('Missing argument: depth (required because tracer was not given).') 66 | if calls is None: 67 | raise TypeError('Missing argument: calls (required because tracer was not given).') 68 | if threading_support is MISSING: 69 | raise TypeError('Missing argument: threading_support (required because tracer was not given).') 70 | else: 71 | depth = tracer.depth 72 | calls = tracer.calls 73 | threading_support = tracer.threading_support 74 | 75 | self.arg = arg 76 | self.frame = frame 77 | self.kind = KIND_NAMES[kind] 78 | self.depth = depth 79 | self.calls = calls 80 | self.threading_support = threading_support 81 | self.detached = False 82 | self.builtin = kind > 3 83 | 84 | self._code = UNSET 85 | self._filename = UNSET 86 | self._fullsource = UNSET 87 | self._function_object = UNSET 88 | self._function = UNSET 89 | self._globals = UNSET 90 | self._lineno = UNSET 91 | self._locals = UNSET 92 | self._module = UNSET 93 | self._source = UNSET 94 | self._stdlib = UNSET 95 | self._threadidn = UNSET 96 | self._threadname = UNSET 97 | self._thread = UNSET 98 | self._instruction = UNSET 99 | 100 | def __repr__(self): 101 | return '' % ( 102 | self.kind, self.function, self.module, self.filename, self.lineno 103 | ) 104 | 105 | def __eq__(self, other): 106 | return self is other 107 | 108 | def detach(self, value_filter=None): 109 | return fast_detach(self, value_filter) 110 | 111 | def clone(self): 112 | return fast_clone(self) 113 | 114 | cdef inline instruction_getter(self): 115 | cdef int position 116 | 117 | if self._instruction is UNSET: 118 | position = PyFrame_GetLasti(self.frame) 119 | co_code = PyCode_GetCode(self.code_getter()) 120 | if co_code and position >= 0: 121 | self._instruction = co_code[position] 122 | else: 123 | self._instruction = None 124 | return self._instruction 125 | 126 | @property 127 | def instruction(self): 128 | return self.instruction_getter() 129 | 130 | cdef inline threadid_getter(self): 131 | cdef long current 132 | 133 | if self._threadidn is UNSET: 134 | current = PyThread_get_thread_ident() 135 | main = get_main_thread() 136 | if main is not None and current == main.ident: 137 | self._threadidn = None 138 | else: 139 | self._threadidn = current 140 | return self._threadidn 141 | 142 | @property 143 | def threadid(self): 144 | return self.threadid_getter() 145 | 146 | cdef inline threadname_getter(self): 147 | if self._threadname is UNSET: 148 | if self._thread is UNSET: 149 | self._thread = current_thread() 150 | self._threadname = self._thread.name 151 | return self._threadname 152 | 153 | @property 154 | def threadname(self): 155 | return self.threadname_getter() 156 | 157 | cdef inline locals_getter(self): 158 | if self._locals is UNSET: 159 | if self.builtin: 160 | self._locals = {} 161 | else: 162 | PyFrame_FastToLocals(self.frame) 163 | self._locals = PyFrame_GetLocals(self.frame) 164 | return self._locals 165 | 166 | @property 167 | def locals(self): 168 | return self.locals_getter() 169 | 170 | cdef inline globals_getter(self): 171 | if self._globals is UNSET: 172 | if self.builtin: 173 | self._locals = {} 174 | else: 175 | self._globals = PyFrame_GetGlobals(self.frame) 176 | return self._globals 177 | 178 | @property 179 | def globals(self): 180 | return self.globals_getter() 181 | 182 | cdef inline function_getter(self): 183 | if self._function is UNSET: 184 | if self.builtin: 185 | self._function = self.arg.__name__ 186 | else: 187 | self._function = self.code_getter().co_name 188 | return self._function 189 | 190 | @property 191 | def function(self): 192 | return self.function_getter() 193 | 194 | @property 195 | def function_object(self): 196 | cdef CodeType code 197 | if self.builtin: 198 | return self.builtin 199 | elif self._function_object is UNSET: 200 | code = self.code_getter() 201 | if code.co_name is None: 202 | return None 203 | # First, try to find the function in globals 204 | candidate = self.globals.get(code.co_name, None) 205 | func = if_same_code(candidate, code) 206 | # If that failed, as will be the case with class and instance methods, try 207 | # to look up the function from the first argument. In the case of class/instance 208 | # methods, this should be the class (or an instance of the class) on which our 209 | # method is defined. 210 | if func is None and code.co_argcount >= 1: 211 | first_arg = self.locals.get(PyCode_GetVarnames(code)[0]) 212 | func = get_func_in_mro(first_arg, code) 213 | # If we still can't find the function, as will be the case with static methods, 214 | # try looking at classes in global scope. 215 | if func is None: 216 | for v in self.globals.values(): 217 | if not isinstance(v, type): 218 | continue 219 | func = get_func_in_mro(v, code) 220 | if func is not None: 221 | break 222 | self._function_object = func 223 | return self._function_object 224 | 225 | cdef inline module_getter(self): 226 | if self._module is UNSET: 227 | if self.builtin: 228 | module = self.arg.__module__ 229 | else: 230 | module = self.globals.get('__name__', '') 231 | if module is None: 232 | module = '?' 233 | self._module = module 234 | return self._module 235 | 236 | @property 237 | def module(self): 238 | return self.module_getter() 239 | 240 | cdef inline filename_getter(self): 241 | cdef CodeType code 242 | if self._filename is UNSET: 243 | code = self.code_getter() 244 | filename = code.co_filename 245 | if not filename: 246 | filename = self.globals.get('__file__') 247 | if not filename: 248 | filename = '?' 249 | elif filename.endswith(('.pyc', '.pyo')): 250 | filename = filename[:-1] 251 | elif filename.endswith(('.so', '.pyd')): 252 | cybasename = CYTHON_SUFFIX_RE.sub('', filename) 253 | for ext in ('.pyx', '.py'): 254 | cyfilename = cybasename + ext 255 | if exists(cyfilename): 256 | filename = cyfilename 257 | break 258 | 259 | self._filename = filename 260 | return self._filename 261 | 262 | @property 263 | def filename(self): 264 | return self.filename_getter() 265 | 266 | cdef inline lineno_getter(self): 267 | if self._lineno is UNSET: 268 | self._lineno = PyFrame_GetLineNumber(self.frame) 269 | return self._lineno 270 | 271 | @property 272 | def lineno(self): 273 | return self.lineno_getter() 274 | 275 | cdef inline CodeType code_getter(self): 276 | if self._code is UNSET: 277 | return PyFrame_GetCode(self.frame) 278 | else: 279 | return self._code 280 | 281 | @property 282 | def code(self): 283 | return self.code_getter() 284 | 285 | cdef inline stdlib_getter(self): 286 | if self._stdlib is UNSET: 287 | module_parts = self.module.split('.') 288 | if 'pkg_resources' in module_parts: 289 | # skip this over-vendored module 290 | self._stdlib = True 291 | elif self.filename == '' and (self.module.startswith('namedtuple_') or self.module == 'site'): 292 | # skip namedtuple exec garbage 293 | self._stdlib = True 294 | elif self.filename.startswith(SITE_PACKAGES_PATHS): 295 | # if in site-packages then definitely not stdlib 296 | self._stdlib = False 297 | elif self.filename.startswith(SYS_PREFIX_PATHS): 298 | self._stdlib = True 299 | else: 300 | self._stdlib = False 301 | return self._stdlib 302 | 303 | @property 304 | def stdlib(self): 305 | return self.stdlib_getter() 306 | 307 | cdef inline fullsource_getter(self): 308 | cdef list lines 309 | cdef CodeType code 310 | 311 | if self._fullsource is UNSET: 312 | try: 313 | self._fullsource = None 314 | code = self.code_getter() 315 | if self.kind == 'call' and code.co_name != '': 316 | lines = [] 317 | try: 318 | for _, token, _, _, _ in generate_tokens( 319 | partial( 320 | next, 321 | yield_lines( 322 | self.filename, 323 | self.frame.f_globals, 324 | self.lineno - 1, 325 | lines, 326 | ), 327 | ) 328 | ): 329 | if token in ('def', 'class', 'lambda'): 330 | self._fullsource = ''.join(lines) 331 | break 332 | except TokenError: 333 | pass 334 | if self._fullsource is None: 335 | self._fullsource = getline(self.filename, self.lineno, self.globals) 336 | except Exception as exc: 337 | self._fullsource = f'??? NO SOURCE: {exc!r}' 338 | return self._fullsource 339 | 340 | @property 341 | def fullsource(self): 342 | return self.fullsource_getter() 343 | 344 | cdef inline source_getter(self): 345 | if self._source is UNSET: 346 | if self.filename.endswith(('.so', '.pyd')): 347 | self._source = f'??? NO SOURCE: not reading binary {splitext(basename(self.filename))[1]} file' 348 | try: 349 | self._source = getline(self.filename, self.lineno, self.globals) 350 | except Exception as exc: 351 | self._source = f'??? NO SOURCE: {exc!r}' 352 | 353 | return self._source 354 | 355 | @property 356 | def source(self): 357 | return self.source_getter() 358 | 359 | def __getitem__(self, item): 360 | return getattr(self, item) 361 | 362 | 363 | def yield_lines(filename, module_globals, start, list collector, 364 | limit=10): 365 | dedent = None 366 | amount = 0 367 | for line in getlines(filename, module_globals)[start:start + limit]: 368 | if dedent is None: 369 | dedent = LEADING_WHITESPACE_RE.findall(line) 370 | dedent = dedent[0] if dedent else '' 371 | amount = len(dedent) 372 | elif not line.startswith(dedent): 373 | break 374 | collector.append(line) 375 | yield line[amount:] 376 | 377 | 378 | cdef inline Event fast_detach(Event self, object value_filter): 379 | event = Event.__new__(Event) 380 | 381 | event._code = self.code_getter() 382 | event._filename = self.filename_getter() 383 | event._fullsource = self.fullsource_getter() 384 | event._function_object = self._function_object 385 | event._function = self.function_getter() 386 | event._lineno = self.lineno_getter() 387 | event._module = self.module_getter() 388 | event._source = self.source_getter() 389 | event._stdlib = self.stdlib_getter() 390 | event._threadidn = self.threadid_getter() 391 | event._threadname = self.threadname_getter() 392 | event._instruction = self.instruction_getter() 393 | 394 | if value_filter: 395 | event._globals = {key: value_filter(value) for key, value in self.globals.items()} 396 | event._locals = {key: value_filter(value) for key, value in self.locals.items()} 397 | event.arg = value_filter(self.arg) 398 | else: 399 | event._globals = {} 400 | event._locals = {} 401 | event.arg = None 402 | 403 | event.builtin = self.builtin 404 | event.calls = self.calls 405 | event.depth = self.depth 406 | event.detached = True 407 | event.kind = self.kind 408 | event.threading_support = self.threading_support 409 | 410 | return event 411 | 412 | cdef inline Event fast_clone(Event self): 413 | event = Event.__new__(Event) 414 | event.arg = self.arg 415 | event.builtin = self.builtin 416 | event.calls = self.calls 417 | event.depth = self.depth 418 | event.detached = False 419 | event.frame = self.frame 420 | event.kind = self.kind 421 | event.threading_support = self.threading_support 422 | event._code = self._code 423 | event._filename = self._filename 424 | event._fullsource = self._fullsource 425 | event._function_object = self._function_object 426 | event._function = self._function 427 | event._globals = self._globals 428 | event._lineno = self._lineno 429 | event._locals = self._locals 430 | event._module = self._module 431 | event._source = self._source 432 | event._stdlib = self._stdlib 433 | event._threadidn = self._threadidn 434 | event._threadname = self._threadname 435 | event._thread = self._thread 436 | event._instruction = self._instruction 437 | return event 438 | -------------------------------------------------------------------------------- /src/hunter/_predicates.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3str 2 | cimport cython 3 | 4 | from ._event cimport Event 5 | 6 | 7 | @cython.final 8 | cdef class Query: 9 | cdef: 10 | readonly tuple query_contains 11 | readonly tuple query_endswith 12 | readonly tuple query_eq 13 | readonly tuple query_gt 14 | readonly tuple query_gte 15 | readonly tuple query_in 16 | readonly tuple query_lt 17 | readonly tuple query_lte 18 | readonly tuple query_regex 19 | readonly tuple query_startswith 20 | 21 | 22 | @cython.final 23 | cdef class And: 24 | cdef: 25 | readonly tuple predicates 26 | 27 | 28 | @cython.final 29 | cdef class Or: 30 | cdef: 31 | readonly tuple predicates 32 | 33 | 34 | @cython.final 35 | cdef class Not: 36 | cdef: 37 | readonly object predicate 38 | 39 | 40 | @cython.final 41 | cdef class When: 42 | cdef: 43 | readonly object condition 44 | readonly tuple actions 45 | 46 | 47 | @cython.final 48 | cdef class From: 49 | cdef: 50 | readonly object condition 51 | readonly object predicate 52 | readonly int watermark 53 | readonly int origin_depth 54 | readonly int origin_calls 55 | 56 | 57 | @cython.final 58 | cdef class Backlog: 59 | cdef: 60 | readonly object condition 61 | readonly int size 62 | readonly int stack 63 | readonly bint vars 64 | readonly bint strip 65 | readonly object action 66 | readonly object _try_repr 67 | readonly object _filter 68 | readonly object queue 69 | 70 | cdef fast_And_call(And self, Event event) 71 | cdef fast_From_call(From self, Event event) 72 | cdef fast_Not_call(Not self, Event event) 73 | cdef fast_Or_call(Or self, Event event) 74 | cdef fast_Query_call(Query self, Event event) 75 | cdef fast_When_call(When self, Event event) 76 | cdef fast_call(object callable, Event event) 77 | -------------------------------------------------------------------------------- /src/hunter/_tracer.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3str 2 | cimport cython 3 | from cpython.pystate cimport Py_tracefunc 4 | from cpython.ref cimport PyObject 5 | 6 | 7 | cdef extern from "vendor/_compat.h": 8 | CodeType PyFrame_GetCode(FrameType) 9 | int PyFrame_GetLasti(FrameType) 10 | object PyCode_GetCode(CodeType) 11 | object PyCode_GetVarnames(CodeType) 12 | object PyFrame_GetGlobals(FrameType) 13 | object PyFrame_GetLocals(FrameType) 14 | 15 | cdef extern from *: 16 | void PyEval_SetTrace(Py_tracefunc, PyObject*) 17 | void PyEval_SetProfile(Py_tracefunc, PyObject*) 18 | 19 | ctypedef extern class types.FrameType[object PyFrameObject, check_size ignore]: 20 | pass 21 | 22 | ctypedef extern class types.CodeType[object PyCodeObject, check_size ignore]: 23 | cdef object co_filename 24 | cdef object co_name 25 | cdef int co_argcount 26 | 27 | cdef extern from "pystate.h": 28 | ctypedef struct PyThreadState: 29 | PyObject* c_traceobj 30 | PyObject* c_profileobj 31 | Py_tracefunc c_tracefunc 32 | Py_tracefunc c_profilefunc 33 | 34 | 35 | @cython.final 36 | cdef class Tracer: 37 | cdef: 38 | readonly object handler 39 | readonly object previous 40 | readonly object threading_support 41 | readonly bint profiling_mode 42 | readonly int depth 43 | readonly int calls 44 | 45 | object __weakref__ 46 | 47 | readonly object _threading_previous 48 | Py_tracefunc _previousfunc 49 | -------------------------------------------------------------------------------- /src/hunter/_tracer.pyx: -------------------------------------------------------------------------------- 1 | # cython: linetrace=True, language_level=3str 2 | import threading 3 | import traceback 4 | 5 | from cpython cimport pystate 6 | from cpython.pystate cimport PyThreadState_Get 7 | 8 | from ._event cimport Event 9 | from ._predicates cimport fast_call 10 | 11 | import hunter 12 | 13 | __all__ = 'Tracer', 14 | 15 | cdef dict KIND_INTS = { 16 | 'call': 0, 17 | 'exception': 1, 18 | 'line': 2, 19 | 'return': 3, 20 | 'c_call': 4, 21 | 'c_exception': 5, 22 | 'c_return': 6, 23 | } 24 | 25 | cdef inline int trace_func(Tracer self, FrameType frame, int kind, PyObject* arg) except -1: 26 | if frame.f_trace is not self: 27 | frame.f_trace = self 28 | 29 | handler = self.handler 30 | 31 | if handler is None: # the tracer was stopped 32 | # make sure it's uninstalled even for running threads 33 | if self.profiling_mode: 34 | PyEval_SetProfile(NULL, NULL) 35 | else: 36 | PyEval_SetTrace(NULL, NULL) 37 | return 0 38 | 39 | if kind == 3 and self.depth > 0: 40 | self.depth -= 1 41 | 42 | cdef Event event = Event(frame, kind, None if arg is NULL else arg, self) 43 | 44 | try: 45 | fast_call(handler, event) 46 | except Exception as exc: 47 | traceback.print_exc(file=hunter._default_stream) 48 | hunter._default_stream.write('Disabling tracer because handler %r failed (%r) at %r.\n\n' % ( 49 | handler, exc, event)) 50 | self.stop() 51 | return 0 52 | 53 | if kind == 0: 54 | self.depth += 1 55 | self.calls += 1 56 | 57 | 58 | cdef class Tracer: 59 | def __cinit__(self, threading_support=None, profiling_mode=False): 60 | self.handler = None 61 | self.previous = None 62 | self._previousfunc = NULL 63 | self._threading_previous = None 64 | self.threading_support = threading_support 65 | self.profiling_mode = profiling_mode 66 | self.depth = 0 67 | self.calls = 0 68 | 69 | def __dealloc__(self): 70 | cdef PyThreadState *state = PyThreadState_Get() 71 | if state.c_traceobj is self: 72 | self.stop() 73 | 74 | def __repr__(self): 75 | return '' % ( 76 | id(self), 77 | self.threading_support, 78 | '' if self.handler is None else 'handler=', 79 | '' if self.handler is None else repr(self.handler), 80 | '' if self.previous is None else ', previous=', 81 | '' if self.previous is None else repr(self.previous), 82 | ) 83 | 84 | def __call__(self, frame, str kind, arg): 85 | trace_func(self, frame, KIND_INTS[kind], arg) 86 | if kind == 0: 87 | PyEval_SetTrace( trace_func, self) 88 | return self 89 | 90 | def trace(self, predicate): 91 | self.handler = predicate 92 | cdef PyThreadState *state = PyThreadState_Get() 93 | 94 | if self.profiling_mode: 95 | if self.threading_support is None or self.threading_support: 96 | self._threading_previous = getattr(threading, '_profile_hook', None) 97 | threading.setprofile(self) 98 | if state.c_profileobj is NULL: 99 | self.previous = None 100 | self._previousfunc = NULL 101 | else: 102 | self.previous = (state.c_profileobj) 103 | self._previousfunc = state.c_profilefunc 104 | PyEval_SetProfile( trace_func, self) 105 | else: 106 | if self.threading_support is None or self.threading_support: 107 | self._threading_previous = getattr(threading, '_trace_hook', None) 108 | threading.settrace(self) 109 | if state.c_traceobj is NULL: 110 | self.previous = None 111 | self._previousfunc = NULL 112 | else: 113 | self.previous = (state.c_traceobj) 114 | self._previousfunc = state.c_tracefunc 115 | PyEval_SetTrace( trace_func, self) 116 | return self 117 | 118 | def stop(self): 119 | if self.handler is not None: 120 | if self.profiling_mode: 121 | if self.previous is None: 122 | PyEval_SetProfile(NULL, NULL) 123 | else: 124 | PyEval_SetProfile(self._previousfunc, self.previous) 125 | self.handler = self.previous = None 126 | self._previousfunc = NULL 127 | if self.threading_support is None or self.threading_support: 128 | threading.setprofile(self._threading_previous) 129 | self._threading_previous = None 130 | else: 131 | if self.previous is None: 132 | PyEval_SetTrace(NULL, NULL) 133 | else: 134 | PyEval_SetTrace(self._previousfunc, self.previous) 135 | self.handler = self.previous = None 136 | self._previousfunc = NULL 137 | if self.threading_support is None or self.threading_support: 138 | threading.settrace(self._threading_previous) 139 | self._threading_previous = None 140 | 141 | def __enter__(self): 142 | return self 143 | 144 | def __exit__(self, exc_type, exc_val, exc_tb): 145 | self.stop() 146 | -------------------------------------------------------------------------------- /src/hunter/config.py: -------------------------------------------------------------------------------- 1 | class Default: 2 | def __init__(self, key, fallback_value): 3 | self.key = key 4 | self.fallback_value = fallback_value 5 | 6 | def resolve(self): 7 | from . import _default_config 8 | 9 | return _default_config.get(self.key, self.fallback_value) 10 | 11 | def __str__(self): 12 | return str(self.fallback_value) 13 | 14 | def __repr__(self): 15 | return repr(self.fallback_value) 16 | 17 | 18 | def resolve(value): 19 | if isinstance(value, Default): 20 | return value.resolve() 21 | else: 22 | return value 23 | -------------------------------------------------------------------------------- /src/hunter/const.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import os 3 | import site 4 | import stat 5 | import sys 6 | import sysconfig 7 | 8 | SITE_PACKAGES_PATHS = set() 9 | for scheme in sysconfig.get_scheme_names(): 10 | if scheme == 'posix_home': 11 | # it would appear this scheme is not for site-packages 12 | continue 13 | for name in ['platlib', 'purelib']: 14 | try: 15 | SITE_PACKAGES_PATHS.add(sysconfig.get_path(name, scheme)) 16 | except KeyError: 17 | pass 18 | if hasattr(site, 'getusersitepackages'): 19 | SITE_PACKAGES_PATHS.add(site.getusersitepackages()) 20 | if sys.version_info < (3, 10): 21 | from distutils.sysconfig import get_python_lib 22 | 23 | SITE_PACKAGES_PATHS.add(get_python_lib()) 24 | SITE_PACKAGES_PATHS.add(os.path.dirname(os.path.dirname(__file__))) 25 | SITE_PACKAGES_PATHS = tuple(SITE_PACKAGES_PATHS) 26 | 27 | SYS_PREFIX_PATHS = { 28 | '', 29 | '', 30 | '', 31 | sys.prefix, 32 | sys.exec_prefix, 33 | os.path.dirname(os.__file__), 34 | os.path.dirname(stat.__file__), 35 | os.path.dirname(collections.__file__), 36 | } 37 | for prop in ( 38 | 'real_prefix', 39 | 'real_exec_prefix', 40 | 'base_prefix', 41 | 'base_exec_prefix', 42 | ): 43 | if hasattr(sys, prop): 44 | SYS_PREFIX_PATHS.add(getattr(sys, prop)) 45 | 46 | SYS_PREFIX_PATHS = tuple(sorted(SYS_PREFIX_PATHS, key=len, reverse=True)) 47 | -------------------------------------------------------------------------------- /src/hunter/event.py: -------------------------------------------------------------------------------- 1 | import linecache 2 | from functools import partial 3 | from os.path import basename 4 | from os.path import exists 5 | from os.path import splitext 6 | from threading import current_thread 7 | from tokenize import TokenError 8 | from tokenize import generate_tokens 9 | 10 | from .const import SITE_PACKAGES_PATHS 11 | from .const import SYS_PREFIX_PATHS 12 | from .util import CYTHON_SUFFIX_RE 13 | from .util import LEADING_WHITESPACE_RE 14 | from .util import MISSING 15 | from .util import cached_property 16 | from .util import get_func_in_mro 17 | from .util import get_main_thread 18 | from .util import if_same_code 19 | 20 | __all__ = ('Event',) 21 | 22 | 23 | class Event: 24 | """ 25 | A wrapper object for Frame objects. Instances of this are passed to your custom functions or predicates. 26 | 27 | Provides few convenience properties. 28 | 29 | Args: 30 | frame (Frame): A python `Frame `_ object. 31 | kind (str): A string like ``'call'``, ``'line'``, ``'return'`` or ``'exception'``. 32 | arg: A value that depends on ``kind``. Usually is ``None`` but for ``'return'`` or ``'exception'`` other values 33 | may be expected. 34 | tracer (:class:`hunter.tracer.Tracer`): The :class:`~hunter.tracer.Tracer` instance that created the event. 35 | Needed for the ``calls`` and ``depth`` fields. 36 | """ 37 | 38 | frame = None 39 | kind = None 40 | arg = None 41 | depth = None 42 | calls = None 43 | builtin = None 44 | 45 | def __init__( 46 | self, 47 | frame, 48 | kind, 49 | arg, 50 | tracer=None, 51 | depth=None, 52 | calls=None, 53 | threading_support=MISSING, 54 | ): 55 | if tracer is None: 56 | if depth is None: 57 | raise TypeError('Missing argument: depth (required because tracer was not given).') 58 | if calls is None: 59 | raise TypeError('Missing argument: calls (required because tracer was not given).') 60 | if threading_support is MISSING: 61 | raise TypeError('Missing argument: threading_support (required because tracer was not given).') 62 | else: 63 | depth = tracer.depth 64 | calls = tracer.calls 65 | threading_support = tracer.threading_support 66 | 67 | #: The original Frame object. 68 | #: 69 | #: .. note:: 70 | #: 71 | #: Not allowed in the builtin predicates (it's the actual Frame object). 72 | #: You may access it from your custom predicate though. 73 | self.frame = frame 74 | 75 | if kind.startswith('c_'): 76 | kind = kind[2:] 77 | builtin = True 78 | else: 79 | builtin = False 80 | 81 | #: If kind of the event is one of ``'c_call'``, ``'c_return'``, or ``'c_exception'`` then this will be True. 82 | #: 83 | #: :type: bool 84 | self.builtin = builtin 85 | 86 | #: The kind of the event, could be one of ``'call'``, ``'line'``, ``'return'``, ``'exception'``. 87 | #: 88 | #: :type: str 89 | self.kind = kind 90 | 91 | #: A value that depends on ``kind`` 92 | self.arg = arg 93 | 94 | #: Tracing depth (increases on calls, decreases on returns). 95 | #: 96 | #: :type: int 97 | self.depth = depth 98 | 99 | #: A counter for total number of calls up to this Event. 100 | #: 101 | #: :type: int 102 | self.calls = calls 103 | 104 | #: A copy of the :attr:`hunter.tracer.Tracer.threading_support` flag. 105 | #: 106 | #: .. note:: 107 | #: 108 | #: Not allowed in the builtin predicates. You may access it from your custom predicate though. 109 | #: 110 | #: :type: bool or None 111 | self.threading_support = threading_support 112 | 113 | #: Flag that is ``True`` if the event was created with :meth:`~hunter.event.Event.detach`. 114 | #: 115 | #: :type: bool 116 | self.detached = False 117 | 118 | def __repr__(self): 119 | return ( 120 | f'' 121 | ) 122 | 123 | def __eq__(self, other): 124 | return self is other 125 | 126 | def __reduce__(self): 127 | raise TypeError("cannot pickle 'hunter.event.Event' object") 128 | 129 | def detach(self, value_filter=None): 130 | """ 131 | Return a copy of the event with references to live objects (like the frame) removed. You should use this if you 132 | want to store or use the event outside the handler. 133 | 134 | You should use this if you want to avoid memory leaks or side-effects when storing the events. 135 | 136 | Args: 137 | value_filter: 138 | Optional callable that takes one argument: ``value``. 139 | 140 | If not specified then the ``arg``, ``globals`` and ``locals`` fields will be ``None``. 141 | 142 | Example usage in a :class:`~hunter.actions.ColorStreamAction` subclass: 143 | 144 | .. sourcecode:: python 145 | 146 | def __call__(self, event): 147 | self.events = [event.detach(lambda field, value: self.try_repr(value))] 148 | 149 | """ 150 | event = Event.__new__(Event) 151 | 152 | event.__dict__['code'] = self.code 153 | event.__dict__['filename'] = self.filename 154 | event.__dict__['fullsource'] = self.fullsource 155 | event.__dict__['function'] = self.function 156 | event.__dict__['lineno'] = self.lineno 157 | event.__dict__['module'] = self.module 158 | event.__dict__['source'] = self.source 159 | event.__dict__['stdlib'] = self.stdlib 160 | event.__dict__['threadid'] = self.threadid 161 | event.__dict__['threadname'] = self.threadname 162 | event.__dict__['instruction'] = self.instruction 163 | 164 | if value_filter: 165 | event.__dict__['arg'] = value_filter(self.arg) 166 | event.__dict__['globals'] = {key: value_filter(value) for key, value in self.globals.items()} 167 | event.__dict__['locals'] = {key: value_filter(value) for key, value in self.locals.items()} 168 | else: 169 | event.__dict__['globals'] = {} 170 | event.__dict__['locals'] = {} 171 | event.__dict__['arg'] = None 172 | 173 | event.threading_support = self.threading_support 174 | event.calls = self.calls 175 | event.depth = self.depth 176 | event.kind = self.kind 177 | event.builtin = self.builtin 178 | 179 | event.detached = True 180 | 181 | return event 182 | 183 | def clone(self): 184 | event = Event.__new__(Event) 185 | event.__dict__ = dict(self.__dict__) 186 | return event 187 | 188 | @cached_property 189 | def instruction(self): 190 | """ 191 | Last byte instruction. If no bytecode was used (Cython code) then it returns ``None``. 192 | Depending on Python version it might be an int or a single char string. 193 | 194 | :type: int or single char string or None 195 | """ 196 | if self.frame.f_lasti >= 0 and self.frame.f_code.co_code: 197 | return self.frame.f_code.co_code[self.frame.f_lasti] 198 | 199 | @cached_property 200 | def threadid(self): 201 | """ 202 | Current thread ident. If current thread is main thread then it returns ``None``. 203 | 204 | :type: int or None 205 | """ 206 | current = self._thread.ident 207 | main = get_main_thread() 208 | if main is None: 209 | return current 210 | else: 211 | return current if current != main.ident else None 212 | 213 | @cached_property 214 | def threadname(self): 215 | """ 216 | Current thread name. 217 | 218 | :type: str 219 | """ 220 | return self._thread.name 221 | 222 | @cached_property 223 | def _thread(self): 224 | return current_thread() 225 | 226 | @cached_property 227 | def locals(self): 228 | """ 229 | A dict with local variables. 230 | 231 | :type: dict 232 | """ 233 | if self.builtin: 234 | return {} 235 | 236 | return self.frame.f_locals 237 | 238 | @cached_property 239 | def globals(self): 240 | """ 241 | A dict with global variables. 242 | 243 | :type: dict 244 | """ 245 | if self.builtin: 246 | return {} 247 | 248 | return self.frame.f_globals 249 | 250 | @cached_property 251 | def function(self): 252 | """ 253 | A string with function name. 254 | 255 | :type: str 256 | """ 257 | if self.builtin: 258 | return self.arg.__name__ 259 | else: 260 | return self.code.co_name 261 | 262 | @cached_property 263 | def function_object(self): 264 | """ 265 | The function instance. 266 | 267 | .. warning:: Use with prudence. 268 | 269 | * Will be ``None`` for decorated functions on Python 2 (methods may still work tho). 270 | * May be ``None`` if tracing functions or classes not defined at module level. 271 | * May be very slow if tracing modules with lots of variables. 272 | 273 | :type: function or None 274 | """ 275 | # Based on MonkeyType's get_func 276 | if self.builtin: 277 | return self.builtin 278 | 279 | code = self.code 280 | if code.co_name is None: 281 | return None 282 | # First, try to find the function in globals 283 | candidate = self.globals.get(code.co_name, None) 284 | func = if_same_code(candidate, code) 285 | # If that failed, as will be the case with class and instance methods, try 286 | # to look up the function from the first argument. In the case of class/instance 287 | # methods, this should be the class (or an instance of the class) on which our 288 | # method is defined. 289 | if func is None and code.co_argcount >= 1: 290 | first_arg = self.locals.get(code.co_varnames[0]) 291 | func = get_func_in_mro(first_arg, code) 292 | # If we still can't find the function, as will be the case with static methods, 293 | # try looking at classes in global scope. 294 | if func is None: 295 | for v in self.globals.values(): 296 | if not isinstance(v, type): 297 | continue 298 | func = get_func_in_mro(v, code) 299 | if func is not None: 300 | break 301 | return func 302 | 303 | @cached_property 304 | def module(self): 305 | """ 306 | A string with module name (like ``'foo.bar'``). 307 | 308 | :type: str 309 | """ 310 | if self.builtin: 311 | module = self.arg.__module__ 312 | else: 313 | module = self.frame.f_globals.get('__name__', '') 314 | if module is None: 315 | module = '?' 316 | return module 317 | 318 | @cached_property 319 | def filename(self): 320 | """ 321 | A string with the path to the module's file. May be empty if ``__file__`` attribute is missing. 322 | May be relative if running scripts. 323 | 324 | :type: str 325 | """ 326 | # if self.builtin: 327 | # return '' 328 | # if self.builtin: 329 | # return '' 330 | filename = self.frame.f_code.co_filename 331 | if not filename: 332 | filename = self.frame.f_globals.get('__file__') 333 | if not filename: 334 | filename = '?' 335 | if filename.endswith(('.pyc', '.pyo')): 336 | filename = filename[:-1] 337 | elif filename.endswith('$py.class'): # Jython 338 | filename = filename[:-9] + '.py' 339 | elif filename.endswith(('.so', '.pyd')): 340 | basename = CYTHON_SUFFIX_RE.sub('', filename) 341 | for ext in ('.pyx', '.py'): 342 | cyfilename = basename + ext 343 | if exists(cyfilename): 344 | filename = cyfilename 345 | break 346 | return filename 347 | 348 | @cached_property 349 | def lineno(self): 350 | """ 351 | An integer with line number in file. 352 | 353 | :type: int 354 | """ 355 | return self.frame.f_lineno 356 | 357 | @cached_property 358 | def code(self): 359 | """ 360 | A code object (not a string). 361 | """ 362 | return self.frame.f_code 363 | 364 | @cached_property 365 | def stdlib(self): 366 | """ 367 | A boolean flag. ``True`` if frame is in stdlib. 368 | 369 | :type: bool 370 | """ 371 | module_parts = self.module.split('.') 372 | if 'pkg_resources' in module_parts: 373 | # skip this over-vendored module 374 | return True 375 | elif self.filename == '' and (self.module.startswith('namedtuple_') or self.module == 'site'): 376 | # skip namedtuple exec garbage 377 | return True 378 | elif self.filename.startswith(SITE_PACKAGES_PATHS): 379 | # if it's in site-packages then its definitely not stdlib 380 | return False 381 | elif self.filename.startswith(SYS_PREFIX_PATHS): 382 | return True 383 | else: 384 | return False 385 | 386 | @cached_property 387 | def fullsource(self): 388 | """ 389 | A string with the sourcecode for the current statement (from ``linecache`` - failures are ignored). 390 | 391 | May include multiple lines if it's a class/function definition (will include decorators). 392 | 393 | :type: str 394 | """ 395 | try: 396 | if self.kind == 'call' and self.code.co_name != '': 397 | lines = [] 398 | try: 399 | for _, token, _, _, _ in generate_tokens( 400 | partial( 401 | next, 402 | yield_lines( 403 | self.filename, 404 | self.frame.f_globals, 405 | self.lineno - 1, 406 | lines.append, 407 | ), 408 | ) 409 | ): 410 | if token in ('def', 'class', 'lambda'): 411 | return ''.join(lines) 412 | except TokenError: 413 | pass 414 | 415 | return linecache.getline(self.filename, self.lineno, self.frame.f_globals) 416 | except Exception as exc: 417 | return f'??? NO SOURCE: {exc!r}' 418 | 419 | @cached_property 420 | def source(self): 421 | """ 422 | A string with the sourcecode for the current line (from ``linecache`` - failures are ignored). 423 | 424 | Fast but sometimes incomplete. 425 | 426 | :type: str 427 | """ 428 | if self.filename.endswith(('.so', '.pyd')): 429 | return f'??? NO SOURCE: not reading binary {splitext(basename(self.filename))[1]} file' 430 | try: 431 | return linecache.getline(self.filename, self.lineno, self.frame.f_globals) 432 | except Exception as exc: 433 | return f'??? NO SOURCE: {exc!r}' 434 | 435 | __getitem__ = object.__getattribute__ 436 | 437 | 438 | def yield_lines( 439 | filename, 440 | module_globals, 441 | start, 442 | collector, 443 | limit=10, 444 | leading_whitespace_re=LEADING_WHITESPACE_RE, 445 | ): 446 | dedent = None 447 | amount = 0 448 | for line in linecache.getlines(filename, module_globals)[start : start + limit]: 449 | if dedent is None: 450 | dedent = leading_whitespace_re.findall(line) 451 | dedent = dedent[0] if dedent else '' 452 | amount = len(dedent) 453 | elif not line.startswith(dedent): 454 | break 455 | collector(line) 456 | yield line[amount:] 457 | -------------------------------------------------------------------------------- /src/hunter/remote.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: S103 S108 S603 2 | import argparse 3 | import errno 4 | import json 5 | import os 6 | import platform 7 | import signal 8 | import socket 9 | import sys 10 | import time 11 | from contextlib import closing 12 | from contextlib import contextmanager 13 | from subprocess import check_call 14 | 15 | import hunter 16 | 17 | if platform.system() == 'Windows': 18 | print('ERROR: This tool does not work on Windows.', file=sys.stderr) 19 | sys.exit(1) 20 | else: 21 | import manhole 22 | from manhole import get_peercred 23 | from manhole.cli import parse_signal 24 | 25 | __all__ = ('install',) 26 | 27 | 28 | def install(**kwargs): 29 | kwargs.setdefault('oneshot_on', 'URG') 30 | kwargs.setdefault('connection_handler', 'exec') 31 | manhole.install(**kwargs) 32 | 33 | 34 | class RemoteStream: 35 | def __init__(self, path, isatty, encoding): 36 | self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 37 | self._sock.connect(path) 38 | self._isatty = isatty 39 | self._encoding = encoding 40 | 41 | def isatty(self): 42 | return self._isatty 43 | 44 | def write(self, data): 45 | try: 46 | if isinstance(data, bytes): 47 | data = data.decode('ascii', 'replace') 48 | self._sock.send(data.encode(self._encoding)) 49 | except Exception as exc: 50 | print( 51 | f'Hunter failed to send trace output {data!r} (encoding: {self._encoding!r}): {exc!r}. Stopping tracer.', 52 | file=sys.stderr, 53 | ) 54 | hunter.stop() 55 | 56 | def flush(self): 57 | pass 58 | 59 | 60 | @contextmanager 61 | def manhole_bootstrap(args, activation_payload, deactivation_payload): 62 | activation_payload += '\nexit()\n' 63 | deactivation_payload += '\nexit()\n' 64 | 65 | activation_payload = activation_payload.encode('utf-8') 66 | deactivation_payload = deactivation_payload.encode('utf-8') 67 | 68 | with closing(connect_manhole(args.pid, args.timeout, args.signal)) as conn: 69 | conn.send(activation_payload) 70 | try: 71 | yield 72 | finally: 73 | with closing(connect_manhole(args.pid, args.timeout, args.signal)) as conn: 74 | conn.send(deactivation_payload) 75 | 76 | 77 | @contextmanager 78 | def gdb_bootstrap(args, activation_payload, deactivation_payload): 79 | print('WARNING: Using GDB may deadlock the process or create unpredictable results!') 80 | activation_command = [ 81 | 'gdb', 82 | '-p', 83 | str(args.pid), 84 | '-batch', 85 | '-ex', 86 | f'call (void)Py_AddPendingCall(PyRun_SimpleString, {json.dumps(activation_payload)})', 87 | ] 88 | deactivation_command = [ 89 | 'gdb', 90 | '-p', 91 | str(args.pid), 92 | '-batch', 93 | '-ex', 94 | f'call (void)Py_AddPendingCall(PyRun_SimpleString, {json.dumps(deactivation_payload)})', 95 | ] 96 | check_call(activation_command) 97 | try: 98 | yield 99 | finally: 100 | check_call(deactivation_command) 101 | 102 | 103 | def connect_manhole(pid, timeout, signal): 104 | os.kill(pid, signal) 105 | 106 | start = time.time() 107 | uds_path = f'/tmp/manhole-{pid}' 108 | conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 109 | conn.settimeout(timeout) 110 | while time.time() - start < timeout: 111 | try: 112 | conn.connect(uds_path) 113 | except OSError as exc: 114 | if exc.errno not in (errno.ENOENT, errno.ECONNREFUSED): 115 | print( 116 | f'Failed to connect to {uds_path!r}: {exc!r}', 117 | file=sys.stderr, 118 | ) 119 | else: 120 | break 121 | else: 122 | print( 123 | f'Failed to connect to {uds_path!r}: Timeout', 124 | file=sys.stderr, 125 | ) 126 | sys.exit(5) 127 | return conn 128 | 129 | 130 | def activate(sink_path, isatty, encoding, options): 131 | stream = hunter._default_stream = RemoteStream(sink_path, isatty, encoding) 132 | try: 133 | stream.write('Output stream active. Starting tracer ...\n\n') 134 | eval(f'hunter.trace({options})') 135 | except Exception as exc: 136 | stream.write( 137 | 'Failed to activate: {!r}. {}\n'.format( 138 | exc, 139 | f'Tracer options where: {options}.' if options else 'No tracer options.', 140 | ) 141 | ) 142 | hunter._default_stream = sys.stderr 143 | raise 144 | 145 | 146 | def deactivate(): 147 | hunter._default_stream = sys.stderr 148 | hunter.stop() 149 | 150 | 151 | parser = argparse.ArgumentParser(description='Trace a process.') 152 | parser.add_argument( 153 | '-p', 154 | '--pid', 155 | metavar='PID', 156 | type=int, 157 | required=True, 158 | help='A numerical process id.', 159 | ) 160 | parser.add_argument('options', metavar='OPTIONS', nargs='*') 161 | parser.add_argument( 162 | '-t', 163 | '--timeout', 164 | dest='timeout', 165 | default=1, 166 | type=float, 167 | help='Timeout to use. Default: %(default)s seconds.', 168 | ) 169 | parser.add_argument( 170 | '--gdb', 171 | dest='gdb', 172 | action='store_true', 173 | help='Use GDB to activate tracing. WARNING: it may deadlock the process!', 174 | ) 175 | parser.add_argument( 176 | '-s', 177 | '--signal', 178 | dest='signal', 179 | type=parse_signal, 180 | metavar='SIGNAL', 181 | default=signal.SIGURG, 182 | help='Send the given SIGNAL to the process before connecting. Default: %(default)s.', 183 | ) 184 | 185 | 186 | def main(): 187 | args = parser.parse_args() 188 | 189 | sink = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 190 | sink_path = f'/tmp/hunter-{os.getpid()}' 191 | sink.bind(sink_path) 192 | sink.listen(1) 193 | os.chmod(sink_path, 0o777) 194 | 195 | stdout = os.fdopen(sys.stdout.fileno(), 'wb', 0) 196 | encoding = getattr(sys.stdout, 'encoding', 'utf-8') or 'utf-8' 197 | bootstrapper = gdb_bootstrap if args.gdb else manhole_bootstrap 198 | payload = 'from hunter import remote; remote.activate({!r}, {!r}, {!r}, {!r})'.format( 199 | sink_path, 200 | sys.stdout.isatty(), 201 | encoding, 202 | ','.join(i.strip(',') for i in args.options), 203 | ) 204 | with bootstrapper(args, payload, 'from hunter import remote; remote.deactivate()'): 205 | conn, _ = sink.accept() 206 | os.unlink(sink_path) 207 | pid, _, _ = get_peercred(conn) 208 | if pid: 209 | if pid != args.pid: 210 | raise Exception(f'Unexpected pid {pid!r} connected to output socket. Was expecting {args.pid!r}.') 211 | else: 212 | print( 213 | 'WARNING: Failed to get pid of connected process.', 214 | file=sys.stderr, 215 | ) 216 | data = conn.recv(1024) 217 | while data: 218 | stdout.write(data) 219 | data = conn.recv(1024) 220 | -------------------------------------------------------------------------------- /src/hunter/tracer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import threading 3 | import traceback 4 | 5 | import hunter 6 | 7 | from .event import Event 8 | 9 | __all__ = ('Tracer',) 10 | 11 | 12 | class Tracer: 13 | """ 14 | Tracer object. 15 | 16 | Args: 17 | threading_support (bool): Hooks the tracer into ``threading.settrace`` as well if True. 18 | """ 19 | 20 | def __init__(self, threading_support=None, profiling_mode=False): 21 | self._handler = None 22 | self._previous = None 23 | self._threading_previous = None 24 | 25 | #: True if threading support was enabled. Should be considered read-only. 26 | #: 27 | #: :type: bool 28 | self.threading_support = threading_support 29 | 30 | #: True if profiling mode was enabled. Should be considered read-only. 31 | #: 32 | #: :type: bool 33 | self.profiling_mode = profiling_mode 34 | 35 | #: Tracing depth (increases on calls, decreases on returns) 36 | #: 37 | #: :type: int 38 | self.depth = 0 39 | 40 | #: A counter for total number of 'call' frames that this Tracer went through. 41 | #: 42 | #: :type: int 43 | self.calls = 0 44 | 45 | @property 46 | def handler(self): 47 | """ 48 | The current predicate. Set via :func:`hunter.Tracer.trace`. 49 | """ 50 | return self._handler 51 | 52 | @property 53 | def previous(self): 54 | """ 55 | The previous tracer, if any (whatever ``sys.gettrace()`` returned prior to :func:`hunter.Tracer.trace`). 56 | """ 57 | return self._previous 58 | 59 | def __repr__(self): 60 | return ''.format( 61 | id(self), 62 | self.threading_support, 63 | '' if self._handler is None else 'handler=', 64 | '' if self._handler is None else repr(self._handler), 65 | '' if self._previous is None else ', previous=', 66 | '' if self._previous is None else repr(self._previous), 67 | ) 68 | 69 | def __call__(self, frame, kind, arg): 70 | """ 71 | The settrace function. 72 | 73 | .. note:: 74 | 75 | This always returns self (drills down) - as opposed to only drilling down when ``predicate(event)`` is True 76 | because it might match further inside. 77 | """ 78 | if self._handler is not None: 79 | if kind == 'return' and self.depth > 0: 80 | self.depth -= 1 81 | event = Event(frame, kind, arg, self) 82 | try: 83 | self._handler(event) 84 | except Exception as exc: 85 | traceback.print_exc(file=hunter._default_stream) 86 | hunter._default_stream.write(f'Disabling tracer because handler {self._handler!r} failed ({exc!r}) at {event!r}.\n\n') 87 | self.stop() 88 | return 89 | if kind == 'call': 90 | self.depth += 1 91 | self.calls += 1 92 | 93 | return self 94 | 95 | def trace(self, predicate): 96 | """ 97 | Starts tracing with the given callable. 98 | 99 | Args: 100 | predicate (callable that accepts a single :obj:`~hunter.event.Event` argument): 101 | Return: 102 | self 103 | """ 104 | self._handler = predicate 105 | if self.profiling_mode: 106 | if self.threading_support is None or self.threading_support: 107 | self._threading_previous = getattr(threading, '_profile_hook', None) 108 | threading.setprofile(self) 109 | self._previous = sys.getprofile() 110 | sys.setprofile(self) 111 | else: 112 | if self.threading_support is None or self.threading_support: 113 | self._threading_previous = getattr(threading, '_trace_hook', None) 114 | threading.settrace(self) 115 | self._previous = sys.gettrace() 116 | sys.settrace(self) 117 | return self 118 | 119 | def stop(self): 120 | """ 121 | Stop tracing. Reinstalls the :attr:`~hunter.tracer.Tracer.previous` tracer. 122 | """ 123 | if self._handler is not None: 124 | if self.profiling_mode: 125 | sys.setprofile(self._previous) 126 | self._handler = self._previous = None 127 | if self.threading_support is None or self.threading_support: 128 | threading.setprofile(self._threading_previous) 129 | self._threading_previous = None 130 | else: 131 | sys.settrace(self._previous) 132 | self._handler = self._previous = None 133 | if self.threading_support is None or self.threading_support: 134 | threading.settrace(self._threading_previous) 135 | self._threading_previous = None 136 | 137 | def __enter__(self): 138 | """ 139 | Does nothing. Users are expected to call :meth:`~hunter.tracer.Tracer.trace`. 140 | 141 | Returns: self 142 | """ 143 | return self 144 | 145 | def __exit__(self, exc_type, exc_val, exc_tb): 146 | """ 147 | Wrapper around :meth:`~hunter.tracer.Tracer.stop`. Does nothing with the arguments. 148 | """ 149 | self.stop() 150 | -------------------------------------------------------------------------------- /src/hunter/util.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import builtins 3 | import re 4 | import types 5 | import weakref 6 | from collections import Counter 7 | from collections import OrderedDict 8 | from collections import defaultdict 9 | from collections import deque 10 | from datetime import date 11 | from datetime import datetime 12 | from datetime import time 13 | from datetime import timedelta 14 | from inspect import CO_VARARGS 15 | from inspect import CO_VARKEYWORDS 16 | from inspect import getattr_static 17 | from re import RegexFlag 18 | from threading import main_thread 19 | 20 | from .vendor.colorama import Back 21 | from .vendor.colorama import Fore 22 | from .vendor.colorama import Style 23 | 24 | InstanceType = type(object()) 25 | 26 | try: 27 | from re import Pattern 28 | except ImportError: 29 | Pattern = type(re.compile('')) 30 | 31 | OTHER_COLORS = { 32 | 'COLON': Style.BRIGHT + Fore.BLACK, 33 | 'LINENO': Style.RESET_ALL, 34 | 'KIND': Fore.CYAN, 35 | 'CONT': Style.BRIGHT + Fore.BLACK, 36 | 'VARS': Style.BRIGHT + Fore.MAGENTA, 37 | 'VARS-NAME': Style.NORMAL + Fore.MAGENTA, 38 | 'INTERNAL-FAILURE': Style.BRIGHT + Back.RED + Fore.RED, 39 | 'INTERNAL-DETAIL': Fore.WHITE, 40 | 'SOURCE-FAILURE': Style.BRIGHT + Back.YELLOW + Fore.YELLOW, 41 | 'SOURCE-DETAIL': Fore.WHITE, 42 | 'BUILTIN': Style.NORMAL + Fore.MAGENTA, 43 | 'RESET': Style.RESET_ALL, 44 | } 45 | for name, group in [ 46 | ('', Style), 47 | ('fore', Fore), 48 | ('back', Back), 49 | ]: 50 | for key in dir(group): 51 | OTHER_COLORS[f'{name}({key})' if name else key] = getattr(group, key) 52 | CALL_COLORS = { 53 | 'call': Style.BRIGHT + Fore.BLUE, 54 | 'line': Fore.RESET, 55 | 'return': Style.BRIGHT + Fore.GREEN, 56 | 'exception': Style.BRIGHT + Fore.RED, 57 | } 58 | CODE_COLORS = { 59 | 'call': Fore.RESET + Style.BRIGHT, 60 | 'line': Fore.RESET, 61 | 'return': Fore.YELLOW, 62 | 'exception': Fore.RED, 63 | } 64 | MISSING = type('MISSING', (), {'__repr__': lambda _: '?'})() 65 | BUILTIN_SYMBOLS = set(vars(builtins)) 66 | CYTHON_SUFFIX_RE = re.compile(r'([.].+)?[.](so|pyd)$', re.IGNORECASE) 67 | LEADING_WHITESPACE_RE = re.compile('(^[ \t]*)(?:[^ \t\n])', re.MULTILINE) 68 | 69 | get_main_thread = weakref.ref(main_thread()) 70 | 71 | 72 | def get_arguments(code): 73 | co_varnames = code.co_varnames 74 | co_argcount = code.co_argcount 75 | co_kwonlyargcount = code.co_kwonlyargcount 76 | kwonlyargs = co_varnames[co_argcount : co_argcount + co_kwonlyargcount] 77 | for arg in co_varnames[:co_argcount]: 78 | yield '', arg, arg 79 | co_argcount += co_kwonlyargcount 80 | if code.co_flags & CO_VARARGS: 81 | arg = co_varnames[co_argcount] 82 | yield '*', arg, arg 83 | co_argcount = co_argcount + 1 84 | for arg in kwonlyargs: 85 | yield '', arg, arg 86 | if code.co_flags & CO_VARKEYWORDS: 87 | arg = co_varnames[co_argcount] 88 | yield '**', arg, arg 89 | 90 | 91 | def flatten(something): 92 | if isinstance(something, (list, tuple)): 93 | for element in something: 94 | yield from flatten(element) 95 | else: 96 | yield something 97 | 98 | 99 | class cached_property: 100 | def __init__(self, func): 101 | self.func = func 102 | self.__doc__ = func.__doc__ 103 | 104 | def __get__(self, obj, cls): 105 | if obj is None: 106 | return self 107 | value = obj.__dict__[self.func.__name__] = self.func(obj) 108 | return value 109 | 110 | 111 | def get_func_in_mro(obj, code): 112 | """Attempt to find a function in a side-effect free way. 113 | 114 | This looks in obj's mro manually and does not invoke any descriptors. 115 | """ 116 | val = getattr_static(obj, code.co_name, None) 117 | if val is None: 118 | return None 119 | if isinstance(val, (classmethod, staticmethod)): 120 | candidate = val.__func__ 121 | elif isinstance(val, property) and (val.fset is None) and (val.fdel is None): 122 | candidate = val.fget 123 | else: 124 | candidate = val 125 | return if_same_code(candidate, code) 126 | 127 | 128 | def if_same_code(func, code): 129 | while func is not None: 130 | func_code = getattr(func, '__code__', None) 131 | if func_code is code: 132 | return func 133 | # Attempt to find the decorated function 134 | func = getattr(func, '__wrapped__', None) 135 | return None 136 | 137 | 138 | def iter_symbols(code): 139 | """ 140 | Iterate all the variable names in the given expression. 141 | 142 | Example: 143 | 144 | * ``self.foobar`` yields ``self`` 145 | * ``self[foobar]`` yields `self`` and ``foobar`` 146 | """ 147 | for node in ast.walk(ast.parse(code)): 148 | if isinstance(node, ast.Name): 149 | yield node.id 150 | 151 | 152 | def safe_repr(obj, maxdepth=5): 153 | if not maxdepth: 154 | return '...' 155 | obj_type = type(obj) 156 | obj_type_type = type(obj_type) 157 | newdepth = maxdepth - 1 158 | 159 | # only represent exact builtins 160 | # (subclasses can have side effects due to __class__ as a property, __instancecheck__, __subclasscheck__ etc.) 161 | if obj_type is dict: 162 | return f'{{{", ".join(f"{safe_repr(k, maxdepth)}: {safe_repr(v, newdepth)}" for k, v in obj.items())}}}' 163 | elif obj_type is list: 164 | return f'[{", ".join(safe_repr(i, newdepth) for i in obj)}]' 165 | elif obj_type is tuple: 166 | return f'({", ".join(safe_repr(i, newdepth) for i in obj)}{"," if len(obj) == 1 else ""})' 167 | elif obj_type is set: 168 | return f'{{{", ".join(safe_repr(i, newdepth) for i in obj)}}}' 169 | elif obj_type is frozenset: 170 | return f'{obj_type.__name__}({{{", ".join(safe_repr(i, newdepth) for i in obj)}}})' 171 | elif obj_type is deque: 172 | return f'{obj_type.__name__}([{", ".join(safe_repr(i, newdepth) for i in obj)}])' 173 | elif obj_type in (Counter, OrderedDict, defaultdict): 174 | return f'{obj_type.__name__}({{{", ".join(f"{safe_repr(k, maxdepth)}: {safe_repr(v, newdepth)}" for k, v in obj.items())}}})' 175 | elif obj_type is Pattern: 176 | if obj.flags: 177 | return f're.compile({safe_repr(obj.pattern)}, flags={RegexFlag(obj.flags)})' 178 | else: 179 | return f're.compile({safe_repr(obj.pattern)})' 180 | elif obj_type in (date, timedelta): 181 | return repr(obj) 182 | elif obj_type is datetime: 183 | return ( 184 | f'{obj_type.__name__}(' 185 | f'{obj.year:d}, ' 186 | f'{obj.month:d}, ' 187 | f'{obj.day:d}, ' 188 | f'{obj.hour:d}, ' 189 | f'{obj.minute:d}, ' 190 | f'{obj.second:d}, ' 191 | f'{obj.microsecond:d}, ' 192 | f'tzinfo={safe_repr(obj.tzinfo)}{f", fold={safe_repr(obj.fold)}" if hasattr(obj, "fold") else ""})' 193 | ) 194 | elif obj_type is time: 195 | return ( 196 | f'{obj_type.__name__}(' 197 | f'{obj.hour:d}, ' 198 | f'{obj.minute:d}, ' 199 | f'{obj.second:d}, ' 200 | f'{obj.microsecond:d}, ' 201 | f'tzinfo={safe_repr(obj.tzinfo)}{f", fold={safe_repr(obj.fold)}" if hasattr(obj, "fold") else ""})' 202 | ) 203 | elif obj_type is types.MethodType: 204 | self = obj.__self__ 205 | name = getattr(obj, '__qualname__', None) 206 | if name is None: 207 | name = obj.__name__ 208 | return f'<{"un" if self is None else ""}bound method {name} of {safe_repr(self, newdepth)}>' 209 | elif obj_type_type is type and BaseException in obj_type.__mro__: 210 | return f'{obj_type.__name__}({", ".join(safe_repr(i, newdepth) for i in obj.args)})' 211 | elif ( 212 | obj_type_type is type 213 | and obj_type is not InstanceType 214 | and obj_type.__module__ in (builtins.__name__, 'io', 'socket', '_socket', 'zoneinfo', 'decimal') 215 | ): 216 | # hardcoded list of safe things. note that isinstance ain't used 217 | # (we don't trust subclasses to do the right thing in __repr__) 218 | return repr(obj) 219 | else: 220 | # if the object has a __dict__ then it's probably an instance of a pure python class, assume bad things 221 | # with side effects will be going on in __repr__ - use the default instead (object.__repr__) 222 | return object.__repr__(obj) 223 | 224 | 225 | def frame_iterator(frame): 226 | """ 227 | Yields frames till there are no more. 228 | """ 229 | while frame: 230 | yield frame 231 | frame = frame.f_back 232 | -------------------------------------------------------------------------------- /src/hunter/vendor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-hunter/5689bae52b2a8e32e7a4e509534486f136330a24/src/hunter/vendor/__init__.py -------------------------------------------------------------------------------- /src/hunter/vendor/_cymem/__init__.pxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/python-hunter/5689bae52b2a8e32e7a4e509534486f136330a24/src/hunter/vendor/_cymem/__init__.pxd -------------------------------------------------------------------------------- /src/hunter/vendor/_cymem/__init__.py: -------------------------------------------------------------------------------- 1 | from .about import * 2 | -------------------------------------------------------------------------------- /src/hunter/vendor/_cymem/about.py: -------------------------------------------------------------------------------- 1 | __title__ = "cymem" 2 | __version__ = "2.0.3" 3 | __summary__ = "Manage calls to calloc/free through Cython" 4 | __uri__ = "https://github.com/explosion/cymem" 5 | __author__ = "Matthew Honnibal" 6 | __email__ = "matt@explosion.ai" 7 | __license__ = "MIT" 8 | -------------------------------------------------------------------------------- /src/hunter/vendor/_cymem/cymem.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3str 2 | ctypedef void* (*malloc_t)(size_t n) 3 | ctypedef void (*free_t)(void *p) 4 | 5 | cdef class PyMalloc: 6 | cdef malloc_t malloc 7 | cdef void _set(self, malloc_t malloc) 8 | 9 | cdef PyMalloc WrapMalloc(malloc_t malloc) 10 | 11 | cdef class PyFree: 12 | cdef free_t free 13 | cdef void _set(self, free_t free) 14 | 15 | cdef PyFree WrapFree(free_t free) 16 | 17 | cdef class Pool: 18 | cdef readonly size_t size 19 | cdef readonly dict addresses 20 | cdef readonly list refs 21 | cdef readonly PyMalloc pymalloc 22 | cdef readonly PyFree pyfree 23 | 24 | cdef void* alloc(self, size_t number, size_t size) except NULL 25 | cdef void free(self, void* addr) except * 26 | cdef void* realloc(self, void* addr, size_t n) except NULL 27 | 28 | 29 | cdef class Address: 30 | cdef void* ptr 31 | cdef readonly PyMalloc pymalloc 32 | cdef readonly PyFree pyfree 33 | -------------------------------------------------------------------------------- /src/hunter/vendor/_cymem/cymem.pyx: -------------------------------------------------------------------------------- 1 | # cython: embedsignature=True, language_level=3str 2 | 3 | from cpython.mem cimport PyMem_Malloc, PyMem_Free 4 | from cpython.ref cimport Py_INCREF, Py_DECREF 5 | from libc.string cimport memset 6 | from libc.string cimport memcpy 7 | import warnings 8 | 9 | WARN_ZERO_ALLOC = False 10 | 11 | cdef class PyMalloc: 12 | cdef void _set(self, malloc_t malloc): 13 | self.malloc = malloc 14 | 15 | cdef PyMalloc WrapMalloc(malloc_t malloc): 16 | cdef PyMalloc o = PyMalloc() 17 | o._set(malloc) 18 | return o 19 | 20 | cdef class PyFree: 21 | cdef void _set(self, free_t free): 22 | self.free = free 23 | 24 | cdef PyFree WrapFree(free_t free): 25 | cdef PyFree o = PyFree() 26 | o._set(free) 27 | return o 28 | 29 | Default_Malloc = WrapMalloc(PyMem_Malloc) 30 | Default_Free = WrapFree(PyMem_Free) 31 | 32 | cdef class Pool: 33 | """Track allocated memory addresses, and free them all when the Pool is 34 | garbage collected. This provides an easy way to avoid memory leaks, and 35 | removes the need for deallocation functions for complicated structs. 36 | 37 | >>> from cymem.cymem cimport Pool 38 | >>> cdef Pool mem = Pool() 39 | >>> data1 = mem.alloc(10, sizeof(int)) 40 | >>> data2 = mem.alloc(12, sizeof(float)) 41 | 42 | Attributes: 43 | size (size_t): The current size (in bytes) allocated by the pool. 44 | addresses (dict): The currently allocated addresses and their sizes. Read-only. 45 | pymalloc (PyMalloc): The allocator to use (default uses PyMem_Malloc). 46 | pyfree (PyFree): The free to use (default uses PyMem_Free). 47 | """ 48 | 49 | def __cinit__(self, PyMalloc pymalloc=Default_Malloc, 50 | PyFree pyfree=Default_Free): 51 | self.size = 0 52 | self.addresses = {} 53 | self.refs = [] 54 | self.pymalloc = pymalloc 55 | self.pyfree = pyfree 56 | 57 | def __dealloc__(self): 58 | cdef size_t addr 59 | if self.addresses is not None: 60 | for addr in self.addresses: 61 | if addr != 0: 62 | self.pyfree.free(addr) 63 | 64 | cdef void* alloc(self, size_t number, size_t elem_size) except NULL: 65 | """Allocate a 0-initialized number*elem_size-byte block of memory, and 66 | remember its address. The block will be freed when the Pool is garbage 67 | collected. Throw warning when allocating zero-length size and 68 | WARN_ZERO_ALLOC was set to True. 69 | """ 70 | if WARN_ZERO_ALLOC and (number == 0 or elem_size == 0): 71 | warnings.warn("Allocating zero bytes") 72 | cdef void* p = self.pymalloc.malloc(number * elem_size) 73 | if p == NULL: 74 | raise MemoryError("Error assigning %d bytes" % (number * elem_size)) 75 | memset(p, 0, number * elem_size) 76 | self.addresses[p] = number * elem_size 77 | self.size += number * elem_size 78 | return p 79 | 80 | cdef void* realloc(self, void* p, size_t new_size) except NULL: 81 | """Resizes the memory block pointed to by p to new_size bytes, returning 82 | a non-NULL pointer to the new block. new_size must be larger than the 83 | original. 84 | 85 | If p is not in the Pool or new_size is 0, a MemoryError is raised. 86 | """ 87 | if p not in self.addresses: 88 | raise ValueError("Pointer %d not found in Pool %s" % (p, self.addresses)) 89 | if new_size == 0: 90 | raise ValueError("Realloc requires new_size > 0") 91 | assert new_size > self.addresses[p] 92 | cdef void* new_ptr = self.alloc(1, new_size) 93 | if new_ptr == NULL: 94 | raise MemoryError("Error reallocating to %d bytes" % new_size) 95 | memcpy(new_ptr, p, self.addresses[p]) 96 | self.free(p) 97 | self.addresses[new_ptr] = new_size 98 | return new_ptr 99 | 100 | cdef void free(self, void* p) except *: 101 | """Frees the memory block pointed to by p, which must have been returned 102 | by a previous call to Pool.alloc. You don't necessarily need to free 103 | memory addresses manually --- you can instead let the Pool be garbage 104 | collected, at which point all the memory will be freed. 105 | 106 | If p is not in Pool.addresses, a KeyError is raised. 107 | """ 108 | self.size -= self.addresses.pop(p) 109 | self.pyfree.free(p) 110 | 111 | def own_pyref(self, object py_ref): 112 | self.refs.append(py_ref) 113 | 114 | 115 | cdef class Address: 116 | """A block of number * size-bytes of 0-initialized memory, tied to a Python 117 | ref-counted object. When the object is garbage collected, the memory is freed. 118 | 119 | >>> from cymem.cymem cimport Address 120 | >>> cdef Address address = Address(10, sizeof(double)) 121 | >>> d10 = address.ptr 122 | 123 | Args: 124 | number (size_t): The number of elements in the memory block. 125 | elem_size (size_t): The size of each element. 126 | 127 | Attributes: 128 | ptr (void*): Pointer to the memory block. 129 | addr (size_t): Read-only size_t cast of the pointer. 130 | pymalloc (PyMalloc): The allocator to use (default uses PyMem_Malloc). 131 | pyfree (PyFree): The free to use (default uses PyMem_Free). 132 | """ 133 | def __cinit__(self, size_t number, size_t elem_size, 134 | PyMalloc pymalloc=Default_Malloc, PyFree pyfree=Default_Free): 135 | self.ptr = NULL 136 | self.pymalloc = pymalloc 137 | self.pyfree = pyfree 138 | 139 | def __init__(self, size_t number, size_t elem_size): 140 | self.ptr = self.pymalloc.malloc(number * elem_size) 141 | if self.ptr == NULL: 142 | raise MemoryError("Error assigning %d bytes" % number * elem_size) 143 | memset(self.ptr, 0, number * elem_size) 144 | 145 | property addr: 146 | def __get__(self): 147 | return self.ptr 148 | 149 | def __dealloc__(self): 150 | if self.ptr != NULL: 151 | self.pyfree.free(self.ptr) 152 | -------------------------------------------------------------------------------- /src/hunter/vendor/colorama/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. 2 | from .initialise import init, deinit, reinit, colorama_text 3 | from .ansi import Fore, Back, Style, Cursor 4 | from .ansitowin32 import AnsiToWin32 5 | 6 | __version__ = '0.4.4' 7 | -------------------------------------------------------------------------------- /src/hunter/vendor/colorama/ansi.py: -------------------------------------------------------------------------------- 1 | # Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. 2 | ''' 3 | This module generates ANSI character codes to printing colors to terminals. 4 | See: http://en.wikipedia.org/wiki/ANSI_escape_code 5 | ''' 6 | 7 | CSI = '\033[' 8 | OSC = '\033]' 9 | BEL = '\a' 10 | 11 | 12 | def code_to_chars(code): 13 | return CSI + str(code) + 'm' 14 | 15 | def set_title(title): 16 | return OSC + '2;' + title + BEL 17 | 18 | def clear_screen(mode=2): 19 | return CSI + str(mode) + 'J' 20 | 21 | def clear_line(mode=2): 22 | return CSI + str(mode) + 'K' 23 | 24 | 25 | class AnsiCodes(object): 26 | def __init__(self): 27 | # the subclasses declare class attributes which are numbers. 28 | # Upon instantiation we define instance attributes, which are the same 29 | # as the class attributes but wrapped with the ANSI escape sequence 30 | for name in dir(self): 31 | if not name.startswith('_'): 32 | value = getattr(self, name) 33 | setattr(self, name, code_to_chars(value)) 34 | 35 | 36 | class AnsiCursor(object): 37 | def UP(self, n=1): 38 | return CSI + str(n) + 'A' 39 | def DOWN(self, n=1): 40 | return CSI + str(n) + 'B' 41 | def FORWARD(self, n=1): 42 | return CSI + str(n) + 'C' 43 | def BACK(self, n=1): 44 | return CSI + str(n) + 'D' 45 | def POS(self, x=1, y=1): 46 | return CSI + str(y) + ';' + str(x) + 'H' 47 | 48 | 49 | class AnsiFore(AnsiCodes): 50 | BLACK = 30 51 | RED = 31 52 | GREEN = 32 53 | YELLOW = 33 54 | BLUE = 34 55 | MAGENTA = 35 56 | CYAN = 36 57 | WHITE = 37 58 | RESET = 39 59 | 60 | # These are fairly well supported, but not part of the standard. 61 | LIGHTBLACK_EX = 90 62 | LIGHTRED_EX = 91 63 | LIGHTGREEN_EX = 92 64 | LIGHTYELLOW_EX = 93 65 | LIGHTBLUE_EX = 94 66 | LIGHTMAGENTA_EX = 95 67 | LIGHTCYAN_EX = 96 68 | LIGHTWHITE_EX = 97 69 | 70 | 71 | class AnsiBack(AnsiCodes): 72 | BLACK = 40 73 | RED = 41 74 | GREEN = 42 75 | YELLOW = 43 76 | BLUE = 44 77 | MAGENTA = 45 78 | CYAN = 46 79 | WHITE = 47 80 | RESET = 49 81 | 82 | # These are fairly well supported, but not part of the standard. 83 | LIGHTBLACK_EX = 100 84 | LIGHTRED_EX = 101 85 | LIGHTGREEN_EX = 102 86 | LIGHTYELLOW_EX = 103 87 | LIGHTBLUE_EX = 104 88 | LIGHTMAGENTA_EX = 105 89 | LIGHTCYAN_EX = 106 90 | LIGHTWHITE_EX = 107 91 | 92 | 93 | class AnsiStyle(AnsiCodes): 94 | BRIGHT = 1 95 | DIM = 2 96 | NORMAL = 22 97 | RESET_ALL = 0 98 | 99 | Fore = AnsiFore() 100 | Back = AnsiBack() 101 | Style = AnsiStyle() 102 | Cursor = AnsiCursor() 103 | -------------------------------------------------------------------------------- /src/hunter/vendor/colorama/ansitowin32.py: -------------------------------------------------------------------------------- 1 | # Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. 2 | import re 3 | import sys 4 | import os 5 | 6 | from .ansi import AnsiFore, AnsiBack, AnsiStyle, Style, BEL 7 | from .winterm import WinTerm, WinColor, WinStyle 8 | from .win32 import windll, winapi_test 9 | 10 | 11 | winterm = None 12 | if windll is not None: 13 | winterm = WinTerm() 14 | 15 | 16 | class StreamWrapper(object): 17 | ''' 18 | Wraps a stream (such as stdout), acting as a transparent proxy for all 19 | attribute access apart from method 'write()', which is delegated to our 20 | Converter instance. 21 | ''' 22 | def __init__(self, wrapped, converter): 23 | # double-underscore everything to prevent clashes with names of 24 | # attributes on the wrapped stream object. 25 | self.__wrapped = wrapped 26 | self.__convertor = converter 27 | 28 | def __getattr__(self, name): 29 | return getattr(self.__wrapped, name) 30 | 31 | def __enter__(self, *args, **kwargs): 32 | # special method lookup bypasses __getattr__/__getattribute__, see 33 | # https://stackoverflow.com/questions/12632894/why-doesnt-getattr-work-with-exit 34 | # thus, contextlib magic methods are not proxied via __getattr__ 35 | return self.__wrapped.__enter__(*args, **kwargs) 36 | 37 | def __exit__(self, *args, **kwargs): 38 | return self.__wrapped.__exit__(*args, **kwargs) 39 | 40 | def write(self, text): 41 | self.__convertor.write(text) 42 | 43 | def isatty(self): 44 | stream = self.__wrapped 45 | if 'PYCHARM_HOSTED' in os.environ: 46 | if stream is not None and (stream is sys.__stdout__ or stream is sys.__stderr__): 47 | return True 48 | try: 49 | stream_isatty = stream.isatty 50 | except AttributeError: 51 | return False 52 | else: 53 | return stream_isatty() 54 | 55 | @property 56 | def closed(self): 57 | stream = self.__wrapped 58 | try: 59 | return stream.closed 60 | except AttributeError: 61 | return True 62 | 63 | 64 | class AnsiToWin32(object): 65 | ''' 66 | Implements a 'write()' method which, on Windows, will strip ANSI character 67 | sequences from the text, and if outputting to a tty, will convert them into 68 | win32 function calls. 69 | ''' 70 | ANSI_CSI_RE = re.compile('\001?\033\\[((?:\\d|;)*)([a-zA-Z])\002?') # Control Sequence Introducer 71 | ANSI_OSC_RE = re.compile('\001?\033\\]([^\a]*)(\a)\002?') # Operating System Command 72 | 73 | def __init__(self, wrapped, convert=None, strip=None, autoreset=False): 74 | # The wrapped stream (normally sys.stdout or sys.stderr) 75 | self.wrapped = wrapped 76 | 77 | # should we reset colors to defaults after every .write() 78 | self.autoreset = autoreset 79 | 80 | # create the proxy wrapping our output stream 81 | self.stream = StreamWrapper(wrapped, self) 82 | 83 | on_windows = os.name == 'nt' 84 | # We test if the WinAPI works, because even if we are on Windows 85 | # we may be using a terminal that doesn't support the WinAPI 86 | # (e.g. Cygwin Terminal). In this case it's up to the terminal 87 | # to support the ANSI codes. 88 | conversion_supported = on_windows and winapi_test() 89 | 90 | # should we strip ANSI sequences from our output? 91 | if strip is None: 92 | strip = conversion_supported or (not self.stream.closed and not self.stream.isatty()) 93 | self.strip = strip 94 | 95 | # should we should convert ANSI sequences into win32 calls? 96 | if convert is None: 97 | convert = conversion_supported and not self.stream.closed and self.stream.isatty() 98 | self.convert = convert 99 | 100 | # dict of ansi codes to win32 functions and parameters 101 | self.win32_calls = self.get_win32_calls() 102 | 103 | # are we wrapping stderr? 104 | self.on_stderr = self.wrapped is sys.stderr 105 | 106 | def should_wrap(self): 107 | ''' 108 | True if this class is actually needed. If false, then the output 109 | stream will not be affected, nor will win32 calls be issued, so 110 | wrapping stdout is not actually required. This will generally be 111 | False on non-Windows platforms, unless optional functionality like 112 | autoreset has been requested using kwargs to init() 113 | ''' 114 | return self.convert or self.strip or self.autoreset 115 | 116 | def get_win32_calls(self): 117 | if self.convert and winterm: 118 | return { 119 | AnsiStyle.RESET_ALL: (winterm.reset_all, ), 120 | AnsiStyle.BRIGHT: (winterm.style, WinStyle.BRIGHT), 121 | AnsiStyle.DIM: (winterm.style, WinStyle.NORMAL), 122 | AnsiStyle.NORMAL: (winterm.style, WinStyle.NORMAL), 123 | AnsiFore.BLACK: (winterm.fore, WinColor.BLACK), 124 | AnsiFore.RED: (winterm.fore, WinColor.RED), 125 | AnsiFore.GREEN: (winterm.fore, WinColor.GREEN), 126 | AnsiFore.YELLOW: (winterm.fore, WinColor.YELLOW), 127 | AnsiFore.BLUE: (winterm.fore, WinColor.BLUE), 128 | AnsiFore.MAGENTA: (winterm.fore, WinColor.MAGENTA), 129 | AnsiFore.CYAN: (winterm.fore, WinColor.CYAN), 130 | AnsiFore.WHITE: (winterm.fore, WinColor.GREY), 131 | AnsiFore.RESET: (winterm.fore, ), 132 | AnsiFore.LIGHTBLACK_EX: (winterm.fore, WinColor.BLACK, True), 133 | AnsiFore.LIGHTRED_EX: (winterm.fore, WinColor.RED, True), 134 | AnsiFore.LIGHTGREEN_EX: (winterm.fore, WinColor.GREEN, True), 135 | AnsiFore.LIGHTYELLOW_EX: (winterm.fore, WinColor.YELLOW, True), 136 | AnsiFore.LIGHTBLUE_EX: (winterm.fore, WinColor.BLUE, True), 137 | AnsiFore.LIGHTMAGENTA_EX: (winterm.fore, WinColor.MAGENTA, True), 138 | AnsiFore.LIGHTCYAN_EX: (winterm.fore, WinColor.CYAN, True), 139 | AnsiFore.LIGHTWHITE_EX: (winterm.fore, WinColor.GREY, True), 140 | AnsiBack.BLACK: (winterm.back, WinColor.BLACK), 141 | AnsiBack.RED: (winterm.back, WinColor.RED), 142 | AnsiBack.GREEN: (winterm.back, WinColor.GREEN), 143 | AnsiBack.YELLOW: (winterm.back, WinColor.YELLOW), 144 | AnsiBack.BLUE: (winterm.back, WinColor.BLUE), 145 | AnsiBack.MAGENTA: (winterm.back, WinColor.MAGENTA), 146 | AnsiBack.CYAN: (winterm.back, WinColor.CYAN), 147 | AnsiBack.WHITE: (winterm.back, WinColor.GREY), 148 | AnsiBack.RESET: (winterm.back, ), 149 | AnsiBack.LIGHTBLACK_EX: (winterm.back, WinColor.BLACK, True), 150 | AnsiBack.LIGHTRED_EX: (winterm.back, WinColor.RED, True), 151 | AnsiBack.LIGHTGREEN_EX: (winterm.back, WinColor.GREEN, True), 152 | AnsiBack.LIGHTYELLOW_EX: (winterm.back, WinColor.YELLOW, True), 153 | AnsiBack.LIGHTBLUE_EX: (winterm.back, WinColor.BLUE, True), 154 | AnsiBack.LIGHTMAGENTA_EX: (winterm.back, WinColor.MAGENTA, True), 155 | AnsiBack.LIGHTCYAN_EX: (winterm.back, WinColor.CYAN, True), 156 | AnsiBack.LIGHTWHITE_EX: (winterm.back, WinColor.GREY, True), 157 | } 158 | return dict() 159 | 160 | def write(self, text): 161 | if self.strip or self.convert: 162 | self.write_and_convert(text) 163 | else: 164 | self.wrapped.write(text) 165 | self.wrapped.flush() 166 | if self.autoreset: 167 | self.reset_all() 168 | 169 | 170 | def reset_all(self): 171 | if self.convert: 172 | self.call_win32('m', (0,)) 173 | elif not self.strip and not self.stream.closed: 174 | self.wrapped.write(Style.RESET_ALL) 175 | 176 | 177 | def write_and_convert(self, text): 178 | ''' 179 | Write the given text to our wrapped stream, stripping any ANSI 180 | sequences from the text, and optionally converting them into win32 181 | calls. 182 | ''' 183 | cursor = 0 184 | text = self.convert_osc(text) 185 | for match in self.ANSI_CSI_RE.finditer(text): 186 | start, end = match.span() 187 | self.write_plain_text(text, cursor, start) 188 | self.convert_ansi(*match.groups()) 189 | cursor = end 190 | self.write_plain_text(text, cursor, len(text)) 191 | 192 | 193 | def write_plain_text(self, text, start, end): 194 | if start < end: 195 | self.wrapped.write(text[start:end]) 196 | self.wrapped.flush() 197 | 198 | 199 | def convert_ansi(self, paramstring, command): 200 | if self.convert: 201 | params = self.extract_params(command, paramstring) 202 | self.call_win32(command, params) 203 | 204 | 205 | def extract_params(self, command, paramstring): 206 | if command in 'Hf': 207 | params = tuple(int(p) if len(p) != 0 else 1 for p in paramstring.split(';')) 208 | while len(params) < 2: 209 | # defaults: 210 | params = params + (1,) 211 | else: 212 | params = tuple(int(p) for p in paramstring.split(';') if len(p) != 0) 213 | if len(params) == 0: 214 | # defaults: 215 | if command in 'JKm': 216 | params = (0,) 217 | elif command in 'ABCD': 218 | params = (1,) 219 | 220 | return params 221 | 222 | 223 | def call_win32(self, command, params): 224 | if command == 'm': 225 | for param in params: 226 | if param in self.win32_calls: 227 | func_args = self.win32_calls[param] 228 | func = func_args[0] 229 | args = func_args[1:] 230 | kwargs = dict(on_stderr=self.on_stderr) 231 | func(*args, **kwargs) 232 | elif command in 'J': 233 | winterm.erase_screen(params[0], on_stderr=self.on_stderr) 234 | elif command in 'K': 235 | winterm.erase_line(params[0], on_stderr=self.on_stderr) 236 | elif command in 'Hf': # cursor position - absolute 237 | winterm.set_cursor_position(params, on_stderr=self.on_stderr) 238 | elif command in 'ABCD': # cursor position - relative 239 | n = params[0] 240 | # A - up, B - down, C - forward, D - back 241 | x, y = {'A': (0, -n), 'B': (0, n), 'C': (n, 0), 'D': (-n, 0)}[command] 242 | winterm.cursor_adjust(x, y, on_stderr=self.on_stderr) 243 | 244 | 245 | def convert_osc(self, text): 246 | for match in self.ANSI_OSC_RE.finditer(text): 247 | start, end = match.span() 248 | text = text[:start] + text[end:] 249 | paramstring, command = match.groups() 250 | if command == BEL: 251 | if paramstring.count(";") == 1: 252 | params = paramstring.split(";") 253 | # 0 - change title and icon (we will only change title) 254 | # 1 - change icon (we don't support this) 255 | # 2 - change title 256 | if params[0] in '02': 257 | winterm.set_title(params[1]) 258 | return text 259 | -------------------------------------------------------------------------------- /src/hunter/vendor/colorama/initialise.py: -------------------------------------------------------------------------------- 1 | # Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. 2 | import atexit 3 | import contextlib 4 | import sys 5 | 6 | from .ansitowin32 import AnsiToWin32 7 | 8 | 9 | orig_stdout = None 10 | orig_stderr = None 11 | 12 | wrapped_stdout = None 13 | wrapped_stderr = None 14 | 15 | atexit_done = False 16 | 17 | 18 | def reset_all(): 19 | if AnsiToWin32 is not None: # Issue #74: objects might become None at exit 20 | AnsiToWin32(orig_stdout).reset_all() 21 | 22 | 23 | def init(autoreset=False, convert=None, strip=None, wrap=True): 24 | 25 | if not wrap and any([autoreset, convert, strip]): 26 | raise ValueError('wrap=False conflicts with any other arg=True') 27 | 28 | global wrapped_stdout, wrapped_stderr 29 | global orig_stdout, orig_stderr 30 | 31 | orig_stdout = sys.stdout 32 | orig_stderr = sys.stderr 33 | 34 | if sys.stdout is None: 35 | wrapped_stdout = None 36 | else: 37 | sys.stdout = wrapped_stdout = \ 38 | wrap_stream(orig_stdout, convert, strip, autoreset, wrap) 39 | if sys.stderr is None: 40 | wrapped_stderr = None 41 | else: 42 | sys.stderr = wrapped_stderr = \ 43 | wrap_stream(orig_stderr, convert, strip, autoreset, wrap) 44 | 45 | global atexit_done 46 | if not atexit_done: 47 | atexit.register(reset_all) 48 | atexit_done = True 49 | 50 | 51 | def deinit(): 52 | if orig_stdout is not None: 53 | sys.stdout = orig_stdout 54 | if orig_stderr is not None: 55 | sys.stderr = orig_stderr 56 | 57 | 58 | @contextlib.contextmanager 59 | def colorama_text(*args, **kwargs): 60 | init(*args, **kwargs) 61 | try: 62 | yield 63 | finally: 64 | deinit() 65 | 66 | 67 | def reinit(): 68 | if wrapped_stdout is not None: 69 | sys.stdout = wrapped_stdout 70 | if wrapped_stderr is not None: 71 | sys.stderr = wrapped_stderr 72 | 73 | 74 | def wrap_stream(stream, convert, strip, autoreset, wrap): 75 | if wrap: 76 | wrapper = AnsiToWin32(stream, 77 | convert=convert, strip=strip, autoreset=autoreset) 78 | if wrapper.should_wrap(): 79 | stream = wrapper.stream 80 | return stream 81 | -------------------------------------------------------------------------------- /src/hunter/vendor/colorama/win32.py: -------------------------------------------------------------------------------- 1 | # Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. 2 | 3 | # from winbase.h 4 | STDOUT = -11 5 | STDERR = -12 6 | 7 | try: 8 | import ctypes 9 | from ctypes import LibraryLoader 10 | windll = LibraryLoader(ctypes.WinDLL) 11 | from ctypes import wintypes 12 | except (AttributeError, ImportError): 13 | windll = None 14 | SetConsoleTextAttribute = lambda *_: None 15 | winapi_test = lambda *_: None 16 | else: 17 | from ctypes import byref, Structure, c_char, POINTER 18 | 19 | COORD = wintypes._COORD 20 | 21 | class CONSOLE_SCREEN_BUFFER_INFO(Structure): 22 | """struct in wincon.h.""" 23 | _fields_ = [ 24 | ("dwSize", COORD), 25 | ("dwCursorPosition", COORD), 26 | ("wAttributes", wintypes.WORD), 27 | ("srWindow", wintypes.SMALL_RECT), 28 | ("dwMaximumWindowSize", COORD), 29 | ] 30 | def __str__(self): 31 | return '(%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d)' % ( 32 | self.dwSize.Y, self.dwSize.X 33 | , self.dwCursorPosition.Y, self.dwCursorPosition.X 34 | , self.wAttributes 35 | , self.srWindow.Top, self.srWindow.Left, self.srWindow.Bottom, self.srWindow.Right 36 | , self.dwMaximumWindowSize.Y, self.dwMaximumWindowSize.X 37 | ) 38 | 39 | _GetStdHandle = windll.kernel32.GetStdHandle 40 | _GetStdHandle.argtypes = [ 41 | wintypes.DWORD, 42 | ] 43 | _GetStdHandle.restype = wintypes.HANDLE 44 | 45 | _GetConsoleScreenBufferInfo = windll.kernel32.GetConsoleScreenBufferInfo 46 | _GetConsoleScreenBufferInfo.argtypes = [ 47 | wintypes.HANDLE, 48 | POINTER(CONSOLE_SCREEN_BUFFER_INFO), 49 | ] 50 | _GetConsoleScreenBufferInfo.restype = wintypes.BOOL 51 | 52 | _SetConsoleTextAttribute = windll.kernel32.SetConsoleTextAttribute 53 | _SetConsoleTextAttribute.argtypes = [ 54 | wintypes.HANDLE, 55 | wintypes.WORD, 56 | ] 57 | _SetConsoleTextAttribute.restype = wintypes.BOOL 58 | 59 | _SetConsoleCursorPosition = windll.kernel32.SetConsoleCursorPosition 60 | _SetConsoleCursorPosition.argtypes = [ 61 | wintypes.HANDLE, 62 | COORD, 63 | ] 64 | _SetConsoleCursorPosition.restype = wintypes.BOOL 65 | 66 | _FillConsoleOutputCharacterA = windll.kernel32.FillConsoleOutputCharacterA 67 | _FillConsoleOutputCharacterA.argtypes = [ 68 | wintypes.HANDLE, 69 | c_char, 70 | wintypes.DWORD, 71 | COORD, 72 | POINTER(wintypes.DWORD), 73 | ] 74 | _FillConsoleOutputCharacterA.restype = wintypes.BOOL 75 | 76 | _FillConsoleOutputAttribute = windll.kernel32.FillConsoleOutputAttribute 77 | _FillConsoleOutputAttribute.argtypes = [ 78 | wintypes.HANDLE, 79 | wintypes.WORD, 80 | wintypes.DWORD, 81 | COORD, 82 | POINTER(wintypes.DWORD), 83 | ] 84 | _FillConsoleOutputAttribute.restype = wintypes.BOOL 85 | 86 | _SetConsoleTitleW = windll.kernel32.SetConsoleTitleW 87 | _SetConsoleTitleW.argtypes = [ 88 | wintypes.LPCWSTR 89 | ] 90 | _SetConsoleTitleW.restype = wintypes.BOOL 91 | 92 | def _winapi_test(handle): 93 | csbi = CONSOLE_SCREEN_BUFFER_INFO() 94 | success = _GetConsoleScreenBufferInfo( 95 | handle, byref(csbi)) 96 | return bool(success) 97 | 98 | def winapi_test(): 99 | return any(_winapi_test(h) for h in 100 | (_GetStdHandle(STDOUT), _GetStdHandle(STDERR))) 101 | 102 | def GetConsoleScreenBufferInfo(stream_id=STDOUT): 103 | handle = _GetStdHandle(stream_id) 104 | csbi = CONSOLE_SCREEN_BUFFER_INFO() 105 | success = _GetConsoleScreenBufferInfo( 106 | handle, byref(csbi)) 107 | return csbi 108 | 109 | def SetConsoleTextAttribute(stream_id, attrs): 110 | handle = _GetStdHandle(stream_id) 111 | return _SetConsoleTextAttribute(handle, attrs) 112 | 113 | def SetConsoleCursorPosition(stream_id, position, adjust=True): 114 | position = COORD(*position) 115 | # If the position is out of range, do nothing. 116 | if position.Y <= 0 or position.X <= 0: 117 | return 118 | # Adjust for Windows' SetConsoleCursorPosition: 119 | # 1. being 0-based, while ANSI is 1-based. 120 | # 2. expecting (x,y), while ANSI uses (y,x). 121 | adjusted_position = COORD(position.Y - 1, position.X - 1) 122 | if adjust: 123 | # Adjust for viewport's scroll position 124 | sr = GetConsoleScreenBufferInfo(STDOUT).srWindow 125 | adjusted_position.Y += sr.Top 126 | adjusted_position.X += sr.Left 127 | # Resume normal processing 128 | handle = _GetStdHandle(stream_id) 129 | return _SetConsoleCursorPosition(handle, adjusted_position) 130 | 131 | def FillConsoleOutputCharacter(stream_id, char, length, start): 132 | handle = _GetStdHandle(stream_id) 133 | char = c_char(char.encode()) 134 | length = wintypes.DWORD(length) 135 | num_written = wintypes.DWORD(0) 136 | # Note that this is hard-coded for ANSI (vs wide) bytes. 137 | success = _FillConsoleOutputCharacterA( 138 | handle, char, length, start, byref(num_written)) 139 | return num_written.value 140 | 141 | def FillConsoleOutputAttribute(stream_id, attr, length, start): 142 | ''' FillConsoleOutputAttribute( hConsole, csbi.wAttributes, dwConSize, coordScreen, &cCharsWritten )''' 143 | handle = _GetStdHandle(stream_id) 144 | attribute = wintypes.WORD(attr) 145 | length = wintypes.DWORD(length) 146 | num_written = wintypes.DWORD(0) 147 | # Note that this is hard-coded for ANSI (vs wide) bytes. 148 | return _FillConsoleOutputAttribute( 149 | handle, attribute, length, start, byref(num_written)) 150 | 151 | def SetConsoleTitle(title): 152 | return _SetConsoleTitleW(title) 153 | -------------------------------------------------------------------------------- /src/hunter/vendor/colorama/winterm.py: -------------------------------------------------------------------------------- 1 | # Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. 2 | from . import win32 3 | 4 | 5 | # from wincon.h 6 | class WinColor(object): 7 | BLACK = 0 8 | BLUE = 1 9 | GREEN = 2 10 | CYAN = 3 11 | RED = 4 12 | MAGENTA = 5 13 | YELLOW = 6 14 | GREY = 7 15 | 16 | # from wincon.h 17 | class WinStyle(object): 18 | NORMAL = 0x00 # dim text, dim background 19 | BRIGHT = 0x08 # bright text, dim background 20 | BRIGHT_BACKGROUND = 0x80 # dim text, bright background 21 | 22 | class WinTerm(object): 23 | 24 | def __init__(self): 25 | self._default = win32.GetConsoleScreenBufferInfo(win32.STDOUT).wAttributes 26 | self.set_attrs(self._default) 27 | self._default_fore = self._fore 28 | self._default_back = self._back 29 | self._default_style = self._style 30 | # In order to emulate LIGHT_EX in windows, we borrow the BRIGHT style. 31 | # So that LIGHT_EX colors and BRIGHT style do not clobber each other, 32 | # we track them separately, since LIGHT_EX is overwritten by Fore/Back 33 | # and BRIGHT is overwritten by Style codes. 34 | self._light = 0 35 | 36 | def get_attrs(self): 37 | return self._fore + self._back * 16 + (self._style | self._light) 38 | 39 | def set_attrs(self, value): 40 | self._fore = value & 7 41 | self._back = (value >> 4) & 7 42 | self._style = value & (WinStyle.BRIGHT | WinStyle.BRIGHT_BACKGROUND) 43 | 44 | def reset_all(self, on_stderr=None): 45 | self.set_attrs(self._default) 46 | self.set_console(attrs=self._default) 47 | self._light = 0 48 | 49 | def fore(self, fore=None, light=False, on_stderr=False): 50 | if fore is None: 51 | fore = self._default_fore 52 | self._fore = fore 53 | # Emulate LIGHT_EX with BRIGHT Style 54 | if light: 55 | self._light |= WinStyle.BRIGHT 56 | else: 57 | self._light &= ~WinStyle.BRIGHT 58 | self.set_console(on_stderr=on_stderr) 59 | 60 | def back(self, back=None, light=False, on_stderr=False): 61 | if back is None: 62 | back = self._default_back 63 | self._back = back 64 | # Emulate LIGHT_EX with BRIGHT_BACKGROUND Style 65 | if light: 66 | self._light |= WinStyle.BRIGHT_BACKGROUND 67 | else: 68 | self._light &= ~WinStyle.BRIGHT_BACKGROUND 69 | self.set_console(on_stderr=on_stderr) 70 | 71 | def style(self, style=None, on_stderr=False): 72 | if style is None: 73 | style = self._default_style 74 | self._style = style 75 | self.set_console(on_stderr=on_stderr) 76 | 77 | def set_console(self, attrs=None, on_stderr=False): 78 | if attrs is None: 79 | attrs = self.get_attrs() 80 | handle = win32.STDOUT 81 | if on_stderr: 82 | handle = win32.STDERR 83 | win32.SetConsoleTextAttribute(handle, attrs) 84 | 85 | def get_position(self, handle): 86 | position = win32.GetConsoleScreenBufferInfo(handle).dwCursorPosition 87 | # Because Windows coordinates are 0-based, 88 | # and win32.SetConsoleCursorPosition expects 1-based. 89 | position.X += 1 90 | position.Y += 1 91 | return position 92 | 93 | def set_cursor_position(self, position=None, on_stderr=False): 94 | if position is None: 95 | # I'm not currently tracking the position, so there is no default. 96 | # position = self.get_position() 97 | return 98 | handle = win32.STDOUT 99 | if on_stderr: 100 | handle = win32.STDERR 101 | win32.SetConsoleCursorPosition(handle, position) 102 | 103 | def cursor_adjust(self, x, y, on_stderr=False): 104 | handle = win32.STDOUT 105 | if on_stderr: 106 | handle = win32.STDERR 107 | position = self.get_position(handle) 108 | adjusted_position = (position.Y + y, position.X + x) 109 | win32.SetConsoleCursorPosition(handle, adjusted_position, adjust=False) 110 | 111 | def erase_screen(self, mode=0, on_stderr=False): 112 | # 0 should clear from the cursor to the end of the screen. 113 | # 1 should clear from the cursor to the beginning of the screen. 114 | # 2 should clear the entire screen, and move cursor to (1,1) 115 | handle = win32.STDOUT 116 | if on_stderr: 117 | handle = win32.STDERR 118 | csbi = win32.GetConsoleScreenBufferInfo(handle) 119 | # get the number of character cells in the current buffer 120 | cells_in_screen = csbi.dwSize.X * csbi.dwSize.Y 121 | # get number of character cells before current cursor position 122 | cells_before_cursor = csbi.dwSize.X * csbi.dwCursorPosition.Y + csbi.dwCursorPosition.X 123 | if mode == 0: 124 | from_coord = csbi.dwCursorPosition 125 | cells_to_erase = cells_in_screen - cells_before_cursor 126 | elif mode == 1: 127 | from_coord = win32.COORD(0, 0) 128 | cells_to_erase = cells_before_cursor 129 | elif mode == 2: 130 | from_coord = win32.COORD(0, 0) 131 | cells_to_erase = cells_in_screen 132 | else: 133 | # invalid mode 134 | return 135 | # fill the entire screen with blanks 136 | win32.FillConsoleOutputCharacter(handle, ' ', cells_to_erase, from_coord) 137 | # now set the buffer's attributes accordingly 138 | win32.FillConsoleOutputAttribute(handle, self.get_attrs(), cells_to_erase, from_coord) 139 | if mode == 2: 140 | # put the cursor where needed 141 | win32.SetConsoleCursorPosition(handle, (1, 1)) 142 | 143 | def erase_line(self, mode=0, on_stderr=False): 144 | # 0 should clear from the cursor to the end of the line. 145 | # 1 should clear from the cursor to the beginning of the line. 146 | # 2 should clear the entire line. 147 | handle = win32.STDOUT 148 | if on_stderr: 149 | handle = win32.STDERR 150 | csbi = win32.GetConsoleScreenBufferInfo(handle) 151 | if mode == 0: 152 | from_coord = csbi.dwCursorPosition 153 | cells_to_erase = csbi.dwSize.X - csbi.dwCursorPosition.X 154 | elif mode == 1: 155 | from_coord = win32.COORD(0, csbi.dwCursorPosition.Y) 156 | cells_to_erase = csbi.dwCursorPosition.X 157 | elif mode == 2: 158 | from_coord = win32.COORD(0, csbi.dwCursorPosition.Y) 159 | cells_to_erase = csbi.dwSize.X 160 | else: 161 | # invalid mode 162 | return 163 | # fill the entire screen with blanks 164 | win32.FillConsoleOutputCharacter(handle, ' ', cells_to_erase, from_coord) 165 | # now set the buffer's attributes accordingly 166 | win32.FillConsoleOutputAttribute(handle, self.get_attrs(), cells_to_erase, from_coord) 167 | 168 | def set_title(self, title): 169 | win32.SetConsoleTitle(title) 170 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from hunter.actions import CallPrinter 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def cleanup_CallPrinter(): 10 | CallPrinter.cleanup() 11 | 12 | 13 | @pytest.fixture(autouse=True) 14 | def cleanup_samples(): 15 | for mod in list(sys.modules): 16 | if mod.startswith('sample'): 17 | del sys.modules[mod] 18 | -------------------------------------------------------------------------------- /tests/eviltracer.pxd: -------------------------------------------------------------------------------- 1 | # cython: language_level=3str 2 | cimport cython 3 | 4 | 5 | cdef extern from "vendor/_compat.h": 6 | """ 7 | #if PY_VERSION_HEX >= 0x030B00A7 8 | #include "internal/pycore_frame.h" 9 | #endif 10 | """ 11 | 12 | ctypedef struct PyObject 13 | 14 | ctypedef class types.FrameType[object PyFrameObject, check_size ignore]: 15 | pass 16 | 17 | 18 | @cython.final 19 | cdef class EvilTracer: 20 | cdef readonly object _calls 21 | cdef readonly object handler 22 | cdef readonly object _tracer 23 | cdef readonly int _stopped 24 | -------------------------------------------------------------------------------- /tests/eviltracer.pyx: -------------------------------------------------------------------------------- 1 | # cython: language_level=3str, c_string_encoding=ascii 2 | import hunter 3 | 4 | from hunter._event cimport Event 5 | from hunter._event cimport fast_detach 6 | 7 | 8 | cdef class EvilTracer: 9 | is_pure = False 10 | 11 | def __init__(self, *args, **kwargs): 12 | self._calls = [] 13 | threading_support = kwargs.pop('threading_support', False) 14 | clear_env_var = kwargs.pop('clear_env_var', False) 15 | self.handler = hunter._prepare_predicate(*args, **kwargs) 16 | self._tracer = hunter.trace(self._append, threading_support=threading_support, clear_env_var=clear_env_var) 17 | self._stopped = False 18 | 19 | def _append(self, Event event): 20 | if self._stopped: 21 | return 22 | detached_event = fast_detach(event, lambda obj: obj) 23 | detached_event.detached = False 24 | detached_event.frame = event.frame 25 | self._calls.append(detached_event) 26 | 27 | def __enter__(self): 28 | self._stopped = False 29 | return self 30 | 31 | def __exit__(self, exc_type, exc_val, exc_tb): 32 | self._stopped = True 33 | self._tracer.stop() 34 | predicate = self.handler 35 | for call in self._calls: 36 | predicate(call) 37 | -------------------------------------------------------------------------------- /tests/sample.py: -------------------------------------------------------------------------------- 1 | if __name__ == '__main__': 2 | import os 3 | 4 | a = os.path.join('a', 'b') 5 | 6 | def foo(): 7 | return os.path.join('a', 'b') 8 | 9 | foo() 10 | -------------------------------------------------------------------------------- /tests/sample2.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | if __name__ == '__main__': # ăăăă 3 | import functools 4 | 5 | def deco(opt): 6 | def decorator(func): 7 | @functools.wraps(func) 8 | def wrapper(*args): 9 | return func(*args) 10 | 11 | return wrapper 12 | 13 | return decorator 14 | 15 | @deco(1) 16 | @deco(2) 17 | @deco(3) 18 | def foo(*args): 19 | return args 20 | 21 | foo( 22 | 'aăă', 23 | 'b', 24 | ) 25 | try: 26 | None( 27 | 'a', 28 | 'b', 29 | ) # dăh! 30 | except: 31 | pass 32 | -------------------------------------------------------------------------------- /tests/sample3.py: -------------------------------------------------------------------------------- 1 | class Bad: 2 | __slots__ = [] 3 | 4 | def __repr__(self): 5 | raise RuntimeError("I'm a bad class!") 6 | 7 | 8 | def a(): 9 | x = Bad() 10 | return x 11 | 12 | 13 | def b(): 14 | x = Bad() 15 | raise Exception(x) 16 | 17 | 18 | a() 19 | try: 20 | b() 21 | except Exception as exc: 22 | print(exc) 23 | -------------------------------------------------------------------------------- /tests/sample4.py: -------------------------------------------------------------------------------- 1 | """nothing""" 2 | -------------------------------------------------------------------------------- /tests/sample5.pyx: -------------------------------------------------------------------------------- 1 | # cython: linetrace=True, language_level=3 2 | a = b = lambda x: x 3 | 4 | 5 | @a 6 | @b 7 | def foo(): 8 | return 1 9 | 10 | 11 | def bar(): 12 | foo() 13 | -------------------------------------------------------------------------------- /tests/sample6.py: -------------------------------------------------------------------------------- 1 | def bar(): 2 | foo() 3 | 4 | 5 | def foo(): 6 | try: 7 | asdf() 8 | except: 9 | pass 10 | try: 11 | asdf() 12 | except: 13 | pass 14 | 15 | 16 | def asdf(): 17 | raise Exception() 18 | -------------------------------------------------------------------------------- /tests/sample7.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import sys 5 | 6 | 7 | def one(): 8 | for i in range(1): # one 9 | two() 10 | 11 | 12 | def two(): 13 | for i in range(1): # two 14 | three() 15 | 16 | 17 | def three(): 18 | for i in range(1): # three 19 | four() 20 | 21 | 22 | def four(): 23 | for i in range(1): # four 24 | five() 25 | 26 | 27 | def five(): 28 | in_five = 1 29 | for i in range(1): # five 30 | return i 31 | 32 | 33 | if __name__ == '__main__': 34 | one() 35 | -------------------------------------------------------------------------------- /tests/sample7args.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | 4 | def one(a=123, b='234', c={'3': [4, '5']}): 5 | for i in range(1): # one 6 | a = b = c['side'] = 'effect' 7 | two() 8 | 9 | 10 | def two(a=123, b='234', c={'3': [4, '5']}): 11 | for i in range(1): # two 12 | a = b = c['side'] = 'effect' 13 | three() 14 | 15 | 16 | def three(a=123, b='234', c={'3': [4, '5']}): 17 | for i in range(1): # three 18 | a = b = c['side'] = 'effect' 19 | four() 20 | 21 | 22 | def four(a=123, b='234', c={'3': [4, '5']}): 23 | for i in range(1): # four 24 | a = b = c['side'] = 'effect' 25 | five() 26 | 27 | 28 | def five(a=123, b='234', c={'3': [4, '5']}): 29 | six() 30 | six() 31 | six() 32 | a = b = c['side'] = in_five = 'effect' 33 | for i in range(1): # five 34 | return i # five 35 | 36 | 37 | def six(): 38 | pass 39 | 40 | 41 | if __name__ == '__main__': 42 | from hunter import * 43 | 44 | from utils import DebugCallPrinter 45 | 46 | trace( 47 | Backlog( 48 | stack=15, 49 | vars=True, 50 | action=DebugCallPrinter(' [' 'backlog' ']'), 51 | function='five', 52 | ).filter(~Q(function='six')), 53 | action=DebugCallPrinter, 54 | ) 55 | one() 56 | one() # make sure Backlog is reusable (doesn't have storage side-effects) 57 | stop() 58 | -------------------------------------------------------------------------------- /tests/sample8errors.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | 5 | 6 | def error(): 7 | raise RuntimeError() 8 | 9 | 10 | def log(msg): 11 | print(msg) 12 | 13 | 14 | def silenced1(): 15 | try: 16 | error() 17 | except Exception: 18 | pass 19 | 20 | 21 | def silenced2(): 22 | try: 23 | error() 24 | except Exception as exc: 25 | log(exc) 26 | for i in range(200): 27 | log(i) 28 | return 'x' 29 | 30 | 31 | def silenced3(): 32 | try: 33 | error() 34 | finally: 35 | return 'mwhahaha' 36 | 37 | 38 | def silenced4(): 39 | try: 40 | error() 41 | except Exception as exc: 42 | logger.info(repr(exc)) 43 | 44 | 45 | def notsilenced(): 46 | try: 47 | error() 48 | except Exception as exc: 49 | raise ValueError(exc) 50 | -------------------------------------------------------------------------------- /tests/samplemanhole.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | 5 | def stuff(): 6 | print('Doing stuff ...', os.getpid()) 7 | time.sleep(1) 8 | 9 | 10 | if __name__ == '__main__': 11 | from hunter import remote 12 | 13 | remote.install() 14 | 15 | while True: 16 | stuff() 17 | -------------------------------------------------------------------------------- /tests/samplepdb.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import hunter 5 | 6 | 7 | def on_postmortem(): 8 | print('Raising stuff ...', os.getpid()) 9 | try: 10 | raise Exception('BOOM!') 11 | except Exception: 12 | pdb.post_mortem() 13 | 14 | 15 | def on_settrace(): 16 | print('Doing stuff ...', os.getpid()) 17 | pdb.set_trace() 18 | 19 | 20 | def one(): 21 | for i in range(2): # one 22 | two() 23 | 24 | 25 | def two(): 26 | for i in range(2): # two 27 | three() 28 | 29 | 30 | def three(): 31 | print('Debugme!') 32 | 33 | 34 | def on_debugger(): 35 | one() 36 | 37 | 38 | if __name__ == '__main__': 39 | if sys.argv[1] == 'pdb': 40 | import pdb 41 | from pdb import Pdb 42 | elif sys.argv[1] == 'ipdb': 43 | import ipdb as pdb 44 | 45 | Pdb = lambda: pdb 46 | 47 | if sys.argv[2] == 'debugger': 48 | with hunter.trace(source__has='Debugme!', action=hunter.Debugger(Pdb)): 49 | on_debugger() 50 | else: 51 | with hunter.trace(module='samplepdb'): 52 | if sys.argv[2] == 'postmortem': 53 | on_postmortem() 54 | elif sys.argv[2] == 'settrace': 55 | on_settrace() 56 | else: 57 | raise RuntimeError(sys.argv) 58 | -------------------------------------------------------------------------------- /tests/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | from __future__ import absolute_import 4 | from __future__ import print_function 5 | 6 | import os 7 | import sys 8 | from glob import glob 9 | from os.path import dirname 10 | from os.path import join 11 | from os.path import relpath 12 | from os.path import splitext 13 | 14 | from setuptools import Extension 15 | from setuptools import setup 16 | 17 | try: 18 | # Allow installing package without any Cython available. This 19 | # assumes you are going to include the .c files in your sdist. 20 | import Cython 21 | except ImportError: 22 | Cython = None 23 | 24 | if __name__ == '__main__': 25 | setup( 26 | package_dir={'': 'tests'}, 27 | zip_safe=False, 28 | setup_requires=( 29 | [ 30 | 'cython', 31 | ] 32 | if Cython 33 | else [] 34 | ), 35 | ext_modules=( 36 | [] 37 | if hasattr(sys, 'pypy_version_info') 38 | else [ 39 | Extension( 40 | splitext(relpath(path, 'tests').replace(os.sep, '.'))[0], 41 | sources=[path], 42 | include_dirs=[dirname(path), 'src', 'src/hunter'], 43 | define_macros=[('CYTHON_TRACE', '1')], 44 | ) 45 | for root, _, _ in os.walk('tests') 46 | for path in glob(join(root, '*.pyx' if Cython else '*.c')) 47 | ] 48 | ), 49 | ) 50 | -------------------------------------------------------------------------------- /tests/simple.py: -------------------------------------------------------------------------------- 1 | """s@tart 2 | 1 3 | 2 4 | 3 5 | 4 6 | """ # end 7 | 8 | 9 | class Foo: 10 | @staticmethod 11 | def a(*args): 12 | return args 13 | 14 | b = staticmethod(lambda *a: a) 15 | 16 | 17 | def deco(_): 18 | return lambda func: lambda *a: func(*a) 19 | 20 | 21 | @deco(1) 22 | @deco(2) 23 | @deco(3) 24 | @deco(4) 25 | def a(*args): 26 | return args 27 | 28 | 29 | Foo.a( 30 | 1, 31 | 2, 32 | 3, 33 | ) 34 | Foo.b( 35 | 1, 36 | 2, 37 | 3, 38 | ) 39 | a() 40 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | import hunter 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def cleanup(): 10 | hunter._default_trace_args = None 11 | hunter._default_config.clear() 12 | yield 13 | hunter._default_trace_args = None 14 | hunter._default_config.clear() 15 | 16 | 17 | @pytest.mark.parametrize( 18 | 'config', 19 | [ 20 | ( 21 | 'foobar', 22 | (('x',), {'y': 1}), 23 | {}, 24 | """Failed to load hunter config from PYTHONHUNTERCONFIG 'foobar': NameError""", 25 | ), 26 | ( 27 | 'foobar=1', 28 | (('x',), {'y': 1}), 29 | {}, 30 | """Discarded config from PYTHONHUNTERCONFIG foobar=1: """, 31 | ), 32 | ( 33 | 'foobar=1, force_colors=1', 34 | (('x',), {'y': 1}), 35 | {'force_colors': 1}, 36 | """Discarded config from PYTHONHUNTERCONFIG foobar=1: """, 37 | ), 38 | ('klass=123', (('x',), {'y': 1}), {'klass': 123}, ''), 39 | ('stream=123', (('x',), {'y': 1}), {'stream': 123}, ''), 40 | ('force_colors=123', (('x',), {'y': 1}), {'force_colors': 123}, ''), 41 | ( 42 | 'filename_alignment=123', 43 | (('x',), {'y': 1}), 44 | {'filename_alignment': 123}, 45 | '', 46 | ), 47 | ( 48 | 'thread_alignment=123', 49 | (('x',), {'y': 1}), 50 | {'thread_alignment': 123}, 51 | '', 52 | ), 53 | ('repr_limit=123', (('x',), {'y': 1}), {'repr_limit': 123}, ''), 54 | ('stdlib=0', (('x',), {'y': 1, 'stdlib': 0}), {}, ''), 55 | ('clear_env_var=1', (('x',), {'y': 1, 'clear_env_var': 1}), {}, ''), 56 | ( 57 | 'threading_support=1', 58 | (('x',), {'y': 1, 'threading_support': 1}), 59 | {}, 60 | '', 61 | ), 62 | ( 63 | 'threads_support=1', 64 | (('x',), {'y': 1, 'threads_support': 1}), 65 | {}, 66 | '', 67 | ), 68 | ('thread_support=1', (('x',), {'y': 1, 'thread_support': 1}), {}, ''), 69 | ( 70 | 'threadingsupport=1', 71 | (('x',), {'y': 1, 'threadingsupport': 1}), 72 | {}, 73 | '', 74 | ), 75 | ('threadssupport=1', (('x',), {'y': 1, 'threadssupport': 1}), {}, ''), 76 | ('threadsupport=1', (('x',), {'y': 1, 'threadsupport': 1}), {}, ''), 77 | ('threading=1', (('x',), {'y': 1, 'threading': 1}), {}, ''), 78 | ('threads=1', (('x',), {'y': 1, 'threads': 1}), {}, ''), 79 | ('thread=1', (('x',), {'y': 1, 'thread': 1}), {}, ''), 80 | ('', (('x',), {'y': 1}), {}, ''), 81 | ], 82 | ids=lambda x: repr(x), 83 | ) 84 | def test_config(monkeypatch, config, capsys): 85 | env, result, defaults, stderr = config 86 | monkeypatch.setitem(os.environ, 'PYTHONHUNTERCONFIG', env) 87 | hunter.load_config() 88 | assert hunter._apply_config(('x',), {'y': 1}) == result 89 | assert hunter._default_config == defaults 90 | output = capsys.readouterr() 91 | assert output.err.startswith(stderr) 92 | 93 | 94 | def test_empty_config(monkeypatch, capsys): 95 | monkeypatch.setitem(os.environ, 'PYTHONHUNTERCONFIG', ' ') 96 | hunter.load_config() 97 | assert hunter._apply_config((), {}) == ((), {}) 98 | assert hunter._default_config == {} 99 | output = capsys.readouterr() 100 | assert output.err == '' 101 | -------------------------------------------------------------------------------- /tests/test_cookbook.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import functools 3 | import os 4 | import sys 5 | from logging import getLogger 6 | from time import time 7 | 8 | import aspectlib 9 | import pytest 10 | 11 | import hunter 12 | from hunter.actions import RETURN_VALUE 13 | from hunter.actions import ColorStreamAction 14 | from hunter.util import safe_repr 15 | 16 | try: 17 | from cStringIO import StringIO 18 | except ImportError: 19 | from io import StringIO 20 | 21 | logger = getLogger(__name__) 22 | 23 | pytest_plugins = ('pytester',) 24 | 25 | 26 | def nothin(x): 27 | return x 28 | 29 | 30 | def bar(): 31 | baz() 32 | 33 | 34 | @nothin 35 | @nothin 36 | @nothin 37 | @nothin 38 | def baz(): 39 | for i in range(10): 40 | os.path.join('a', str(i)) 41 | foo = 1 42 | 43 | 44 | def brief_probe(qualname, *actions, **kwargs): 45 | return aspectlib.weave(qualname, functools.partial(hunter.wrap, actions=actions, **kwargs)) 46 | 47 | 48 | def fast_probe(qualname, *actions, **filters): 49 | def tracing_decorator(func): 50 | @functools.wraps(func) 51 | def tracing_wrapper(*args, **kwargs): 52 | # create the Tracer manually to avoid spending time in likely useless things like: 53 | # - loading PYTHONHUNTERCONFIG 54 | # - setting up the clear_env_var or thread_support options 55 | # - atexit cleanup registration 56 | with hunter.Tracer().trace(hunter.When(hunter.Query(**filters), *actions)): 57 | return func(*args, **kwargs) 58 | 59 | return tracing_wrapper 60 | 61 | return aspectlib.weave(qualname, tracing_decorator) # this does the monkeypatch 62 | 63 | 64 | @contextlib.contextmanager 65 | def no_probe(*args, **kwargs): 66 | yield 67 | 68 | 69 | @pytest.mark.parametrize('impl', [fast_probe, brief_probe, no_probe]) 70 | def test_probe(impl, benchmark): 71 | with open(os.devnull, 'w') as stream: 72 | with impl( 73 | '%s.baz' % __name__, 74 | hunter.VarsPrinter('foo', stream=stream), 75 | kind='return', 76 | depth=0, 77 | ): 78 | benchmark(bar) 79 | 80 | 81 | class ProfileAction(ColorStreamAction): 82 | # using ColorStreamAction brings this more in line with the other actions 83 | # (stream option, coloring and such, see the other examples for colors) 84 | def __init__(self, **kwargs): 85 | self.timings = {} 86 | super(ProfileAction, self).__init__(**kwargs) 87 | 88 | def __call__(self, event): 89 | current_time = time() 90 | # include event.builtin in the id so we don't have problems 91 | # with Python reusing frame objects from the previous call for builtin calls 92 | frame_id = id(event.frame), str(event.builtin) 93 | 94 | if event.kind == 'call': 95 | self.timings[frame_id] = current_time, None 96 | elif frame_id in self.timings: 97 | start_time, exception = self.timings.pop(frame_id) 98 | 99 | # try to find a complete function name for display 100 | function_object = event.function_object 101 | if event.builtin: 102 | function = '.{}'.format(event.arg.__name__) 103 | elif function_object: 104 | if hasattr(function_object, '__qualname__'): 105 | function = '{}.{}'.format( 106 | function_object.__module__, 107 | function_object.__qualname__, 108 | ) 109 | else: 110 | function = '{}.{}'.format(function_object.__module__, function_object.__name__) 111 | else: 112 | function = event.function 113 | 114 | if event.kind == 'exception': 115 | # store the exception 116 | # (there will be a followup 'return' event in which we deal with it) 117 | self.timings[frame_id] = start_time, event.arg 118 | elif event.kind == 'return': 119 | delta = current_time - start_time 120 | if event.instruction == RETURN_VALUE: 121 | # exception was discarded 122 | self.output( 123 | '{fore(BLUE)}{} returned: {}. Duration: {:.4f}s{RESET}\n', 124 | function, 125 | safe_repr(event.arg), 126 | delta, 127 | ) 128 | else: 129 | self.output( 130 | '{fore(RED)}{} raised exception: {}. Duration: {:.4f}s{RESET}\n', 131 | function, 132 | safe_repr(exception), 133 | delta, 134 | ) 135 | 136 | 137 | @pytest.mark.xfail( 138 | sys.version_info.major == 3 and sys.version_info.minor == 12, 139 | reason="broken on 3.12, fixme", 140 | ) 141 | @pytest.mark.parametrize( 142 | 'options', 143 | [{'kind__in': ['call', 'return', 'exception']}, {'profile': True}], 144 | ids=['kind__in=call,return,exception', 'profile=True'], 145 | ) 146 | def test_profile(LineMatcher, options): 147 | stream = StringIO() 148 | with hunter.trace(action=ProfileAction(stream=stream), **options): 149 | from sample8errors import notsilenced 150 | from sample8errors import silenced1 151 | from sample8errors import silenced3 152 | from sample8errors import silenced4 153 | 154 | silenced1() 155 | print('Done silenced1') 156 | silenced3() 157 | print('Done silenced3') 158 | silenced4() 159 | print('Done silenced4') 160 | 161 | try: 162 | notsilenced() 163 | except ValueError: 164 | print('Done not silenced') 165 | 166 | lm = LineMatcher(stream.getvalue().splitlines()) 167 | if 'profile' in options: 168 | lm.fnmatch_lines( 169 | [ 170 | 'sample8errors.error raised exception: None. Duration: ?.????s', 171 | 'sample8errors.silenced1 returned: None. Duration: ?.????s', 172 | 'sample8errors.error raised exception: None. Duration: ?.????s', 173 | 'sample8errors.silenced3 returned: \'mwhahaha\'. Duration: ?.????s', 174 | 'sample8errors.error raised exception: None. Duration: ?.????s', 175 | '.repr raised exception: None. Duration: ?.????s', 176 | 'sample8errors.silenced4 returned: None. Duration: ?.????s', 177 | 'sample8errors.error raised exception: None. Duration: ?.????s', 178 | 'sample8errors.notsilenced raised exception: None. Duration: ?.????s', 179 | ] 180 | ) 181 | else: 182 | lm.fnmatch_lines( 183 | [ 184 | 'sample8errors.error raised exception: (*RuntimeError*, *). Duration: ?.????s', 185 | 'sample8errors.silenced1 returned: None. Duration: ?.????s', 186 | 'sample8errors.error raised exception: (*RuntimeError*, *). Duration: ?.????s', 187 | 'sample8errors.silenced3 returned: \'mwhahaha\'. Duration: ?.????s', 188 | 'sample8errors.error raised exception: (*RuntimeError*, *). Duration: ?.????s', 189 | 'sample8errors.silenced4 returned: None. Duration: ?.????s', 190 | 'sample8errors.error raised exception: (*RuntimeError*, *). Duration: ?.????s', 191 | 'sample8errors.notsilenced raised exception: (*ValueError(RuntimeError*, *). Duration: ?.????s', 192 | ] 193 | ) 194 | -------------------------------------------------------------------------------- /tests/test_predicates.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import sys 4 | 5 | import pytest 6 | 7 | import hunter 8 | from hunter import And 9 | from hunter import Backlog 10 | from hunter import CallPrinter 11 | from hunter import CodePrinter 12 | from hunter import Debugger 13 | from hunter import From 14 | from hunter import Manhole 15 | from hunter import Not 16 | from hunter import Or 17 | from hunter import Q 18 | from hunter import Query 19 | from hunter import When 20 | from hunter import _Backlog 21 | from hunter.actions import ColorStreamAction 22 | 23 | 24 | class FakeCallable: 25 | def __init__(self, value): 26 | self.value = value 27 | 28 | def __call__(self): 29 | raise NotImplementedError('Nope') 30 | 31 | def __repr__(self): 32 | return repr(self.value) 33 | 34 | def __str__(self): 35 | return str(self.value) 36 | 37 | def __eq__(self, other): 38 | return self.value == other.value 39 | 40 | def __hash__(self): 41 | return hash(self.value) 42 | 43 | 44 | C = FakeCallable 45 | 46 | 47 | @pytest.fixture 48 | def mockevent(): 49 | return hunter.Event( 50 | sys._getframe(0), 51 | 2 if '_event' in hunter.Event.__module__ else 'line', 52 | None, 53 | hunter.Tracer(), 54 | ) 55 | 56 | 57 | def test_no_inf_recursion(mockevent): 58 | assert Or(And(1)) == 1 59 | assert Or(Or(1)) == 1 60 | assert And(Or(1)) == 1 61 | assert And(And(1)) == 1 62 | predicate = Q(Q(lambda ev: 1, module='wat')) 63 | print('predicate:', predicate) 64 | predicate(mockevent) 65 | 66 | 67 | def test_compression(): 68 | assert Or(Or(1, 2), And(3)) == Or(1, 2, 3) 69 | assert Or(Or(1, 2), 3) == Or(1, 2, 3) 70 | assert Or(1, Or(2, 3), 4) == Or(1, 2, 3, 4) 71 | assert And(1, 2, Or(3, 4)).predicates == (1, 2, Or(3, 4)) 72 | 73 | assert repr(Or(Or(1, 2), And(3))) == repr(Or(1, 2, 3)) 74 | assert repr(Or(Or(1, 2), 3)) == repr(Or(1, 2, 3)) 75 | assert repr(Or(1, Or(2, 3), 4)) == repr(Or(1, 2, 3, 4)) 76 | 77 | 78 | def test_from_kwargs_split(): 79 | assert From(module=1, depth=2, depth_lt=3) == From(Query(module=1), Query(depth=2, depth_lt=3)) 80 | assert repr(From(module=1, depth=2, depth_lt=3)).replace(', " 82 | "predicate=, watermark=0>" 83 | ) 84 | 85 | 86 | def test_not(mockevent): 87 | assert Not(1).predicate == 1 88 | assert ~Or(1, 2) == Not(Or(1, 2)) 89 | assert ~And(1, 2) == Not(And(1, 2)) 90 | 91 | assert ~Not(1) == 1 92 | 93 | assert ~Query(module=1) | ~Query(module=2) == Not(And(Query(module=1), Query(module=2))) 94 | assert ~Query(module=1) & ~Query(module=2) == Not(Or(Query(module=1), Query(module=2))) 95 | 96 | assert ~Query(module=1) | Query(module=2) == Or(Not(Query(module=1)), Query(module=2)) 97 | assert ~Query(module=1) & Query(module=2) == And(Not(Query(module=1)), Query(module=2)) 98 | 99 | assert ~(Query(module=1) & Query(module=2)) == Not(And(Query(module=1), Query(module=2))) 100 | assert ~(Query(module=1) | Query(module=2)) == Not(Or(Query(module=1), Query(module=2))) 101 | 102 | assert repr(~Or(1, 2)) == repr(Not(Or(1, 2))) 103 | assert repr(~And(1, 2)) == repr(Not(And(1, 2))) 104 | 105 | assert repr(~Query(module=1) | ~Query(module=2)) == repr(Not(And(Query(module=1), Query(module=2)))) 106 | assert repr(~Query(module=1) & ~Query(module=2)) == repr(Not(Or(Query(module=1), Query(module=2)))) 107 | 108 | assert repr(~(Query(module=1) & Query(module=2))) == repr(Not(And(Query(module=1), Query(module=2)))) 109 | assert repr(~(Query(module=1) | Query(module=2))) == repr(Not(Or(Query(module=1), Query(module=2)))) 110 | 111 | assert Not(Q(module=__name__))(mockevent) is False 112 | 113 | 114 | def test_query_allowed(): 115 | pytest.raises(TypeError, Query, 1) 116 | pytest.raises(TypeError, Query, a=1) 117 | 118 | 119 | def test_when_allowed(): 120 | pytest.raises(TypeError, When, 1) 121 | 122 | 123 | @pytest.mark.parametrize( 124 | 'expr,expected', 125 | [ 126 | ({'module': __name__}, True), 127 | ({'module': __name__ + '.'}, False), 128 | ({'module_startswith': 'test'}, True), 129 | ({'module__startswith': 'test'}, True), 130 | ({'module_contains': 'test'}, True), 131 | ({'module_contains': 'foo'}, False), 132 | ({'module_endswith': 'foo'}, False), 133 | ({'module__endswith': __name__.split('_')[-1]}, True), 134 | ({'module_in': __name__}, True), 135 | ({'module': 'abcd'}, False), 136 | ({'module': ['abcd']}, False), 137 | ({'module_in': ['abcd']}, False), 138 | ({'module_in': ['a', __name__, 'd']}, True), 139 | ({'module': 'abcd'}, False), 140 | ({'module_startswith': ('abc', 'test')}, True), 141 | ({'module_startswith': {'abc', 'test'}}, True), 142 | ({'module_startswith': ['abc', 'test']}, True), 143 | ({'module_startswith': ('abc', 'test')}, True), 144 | ({'module_startswith': ('abc', 'test')}, True), 145 | ({'module_startswith': ('abc', 'xyz')}, False), 146 | ({'module_endswith': ('abc', __name__.split('_')[-1])}, True), 147 | ({'module_endswith': {'abc', __name__.split('_')[-1]}}, True), 148 | ({'module_endswith': ['abc', __name__.split('_')[-1]]}, True), 149 | ({'module_endswith': ('abc', 'xyz')}, False), 150 | ({'module': 'abc'}, False), 151 | ({'module_regex': r'(_|_.*)\b'}, False), 152 | ({'module_regex': r'.+_.+$'}, True), 153 | ({'module_regex': r'(test|test.*)\b'}, True), 154 | ({'calls_gte': 0}, True), 155 | ({'calls_gt': 0}, False), 156 | ({'calls_lte': 0}, True), 157 | ({'calls_lt': 0}, False), 158 | ({'calls_gte': 1}, False), 159 | ({'calls_gt': -1}, True), 160 | ({'calls_lte': -1}, False), 161 | ({'calls_lt': 1}, True), 162 | ], 163 | ) 164 | def test_matching(expr, mockevent, expected): 165 | assert Query(**expr)(mockevent) == expected 166 | 167 | 168 | @pytest.mark.parametrize( 169 | 'exc_type,expr', 170 | [ 171 | (TypeError, {'module_1': 1}), 172 | (TypeError, {'module1': 1}), 173 | (ValueError, {'module_startswith': 1}), 174 | (ValueError, {'module_startswith': {1: 2}}), 175 | (ValueError, {'module_endswith': 1}), 176 | (ValueError, {'module_endswith': {1: 2}}), 177 | (TypeError, {'module_foo': 1}), 178 | (TypeError, {'module_a_b': 1}), 179 | ], 180 | ) 181 | def test_bad_query(expr, exc_type): 182 | pytest.raises(exc_type, Query, **expr) 183 | 184 | 185 | def test_when(mockevent): 186 | called = [] 187 | assert When(Q(module='foo'), lambda ev: called.append(ev))(mockevent) is False 188 | assert called == [] 189 | 190 | assert When(Q(module=__name__), lambda ev: called.append(ev))(mockevent) is True 191 | assert called == [mockevent] 192 | 193 | called = [] 194 | assert Q(module=__name__, action=lambda ev: called.append(ev))(mockevent) is True 195 | assert called == [mockevent] 196 | 197 | called = [[], []] 198 | predicate = Q(module=__name__, action=lambda ev: called[0].append(ev)) | Q(module='foo', action=lambda ev: called[1].append(ev)) 199 | assert predicate(mockevent) is True 200 | assert called == [[mockevent], []] 201 | 202 | assert predicate(mockevent) is True 203 | assert called == [[mockevent, mockevent], []] 204 | 205 | called = [[], []] 206 | predicate = Q(module=__name__, action=lambda ev: called[0].append(ev)) & Q(function='mockevent', action=lambda ev: called[1].append(ev)) 207 | assert predicate(mockevent) is True 208 | assert called == [[mockevent], [mockevent]] 209 | 210 | 211 | def test_from(mockevent): 212 | pytest.raises((AttributeError, TypeError), From(), 1) 213 | assert From()(mockevent) is True 214 | 215 | called = [] 216 | assert From(Q(module='foo') | Q(module='bar'), lambda ev: called.append(ev))(mockevent) is False 217 | assert called == [] 218 | 219 | assert ( 220 | From( 221 | Not(Q(module='foo') | Q(module='bar')), 222 | lambda ev: called.append(ev), 223 | )(mockevent) 224 | is None 225 | ) 226 | assert called 227 | 228 | called = [] 229 | assert From(Q(module=__name__), lambda ev: called.append(ev))(mockevent) is None 230 | assert called 231 | 232 | 233 | def test_backlog(mockevent): 234 | assert Backlog(module=__name__)(mockevent) is True 235 | 236 | class Action(ColorStreamAction): 237 | called = [] 238 | 239 | def __call__(self, event): 240 | self.called.append(event) 241 | 242 | assert Backlog(Q(module='foo') | Q(module='bar'), action=Action)(mockevent) is False 243 | assert Action.called == [] 244 | 245 | backlog = Backlog(Not(Q(module='foo') | Q(module='bar')), action=Action) 246 | assert backlog(mockevent) is True 247 | assert backlog(mockevent) is True 248 | assert Action.called == [] 249 | 250 | def predicate(ev, store=[]): 251 | store.append(1) 252 | return len(store) > 2 253 | 254 | backlog = Backlog(predicate, action=Action, stack=0) 255 | 256 | assert backlog(mockevent) is False 257 | assert backlog(mockevent) is False 258 | assert backlog(mockevent) is True 259 | assert len(Action.called) == 1 260 | 261 | 262 | def test_backlog_action_setup(): 263 | assert isinstance(Backlog(module=1).action, CallPrinter) 264 | assert isinstance(Backlog(module=1, action=CodePrinter).action, CodePrinter) 265 | 266 | class FakeAction(ColorStreamAction): 267 | pass 268 | 269 | assert isinstance(Backlog(module=1, action=FakeAction).action, FakeAction) 270 | 271 | 272 | def test_and_or_kwargs(): 273 | assert And(1, function=2) == And(1, Query(function=2)) 274 | assert Or(1, function=2) == Or(1, Query(function=2)) 275 | 276 | 277 | def test_from_typeerror(): 278 | pytest.raises(TypeError, From, 1, 2, kind=3) 279 | pytest.raises(TypeError, From, 1, function=2) 280 | pytest.raises(TypeError, From, junk=1) 281 | 282 | 283 | def test_backlog_typeerror(): 284 | pytest.raises(TypeError, Backlog) 285 | pytest.raises(TypeError, Backlog, junk=1) 286 | pytest.raises(TypeError, Backlog, action=1) 287 | pytest.raises(TypeError, Backlog, module=1, action=1) 288 | pytest.raises(TypeError, Backlog, module=1, action=type) 289 | 290 | 291 | def test_backlog_filter(): 292 | class MyAction(ColorStreamAction): 293 | def __eq__(self, other): 294 | return True 295 | 296 | assert Backlog(Q(), action=MyAction).filter(function=1) == _Backlog(condition=Q(), filter=Query(function=1), action=MyAction) 297 | assert Backlog(Q(), action=MyAction, filter=Q(module=1)).filter(function=2) == _Backlog( 298 | condition=Q(), 299 | filter=And(Query(module=1), Query(function=2)), 300 | action=MyAction, 301 | ) 302 | 303 | def blabla(): 304 | pass 305 | 306 | assert Backlog(Q(), action=MyAction, filter=blabla).filter(function=1) == _Backlog( 307 | condition=Q(), filter=And(blabla, Query(function=1)), action=MyAction 308 | ) 309 | assert Backlog(Q(), action=MyAction, filter=Q(module=1)).filter(blabla, function=2) == _Backlog( 310 | condition=Q(), 311 | filter=And(Query(module=1), blabla, Query(function=2)), 312 | action=MyAction, 313 | ) 314 | 315 | 316 | def test_and(mockevent): 317 | assert And(C(1), C(2)) == And(C(1), C(2)) 318 | assert Q(module=1) & Q(module=2) == And(Q(module=1), Q(module=2)) 319 | assert Q(module=1) & Q(module=2) & Q(module=3) == And(Q(module=1), Q(module=2), Q(module=3)) 320 | 321 | assert (Q(module=__name__) & Q(module='foo'))(mockevent) is False 322 | assert (Q(module=__name__) & Q(function='mockevent'))(mockevent) is True 323 | 324 | assert And(1, 2) | 3 == Or(And(1, 2), 3) 325 | 326 | 327 | def test_or(mockevent): 328 | assert Q(module=1) | Q(module=2) == Or(Q(module=1), Q(module=2)) 329 | assert Q(module=1) | Q(module=2) | Q(module=3) == Or(Q(module=1), Q(module=2), Q(module=3)) 330 | 331 | assert (Q(module='foo') | Q(module='bar'))(mockevent) is False 332 | assert (Q(module='foo') | Q(module=__name__))(mockevent) is True 333 | 334 | assert Or(1, 2) & 3 == And(Or(1, 2), 3) 335 | 336 | 337 | def test_str_repr(): 338 | assert repr(Q(module='a', function='b')).endswith("predicates.Query: query_eq=(('function', 'b'), ('module', 'a'))>") 339 | assert str(Q(module='a', function='b')) == "Query(function='b', module='a')" 340 | 341 | assert repr(Q(module='a')).endswith("predicates.Query: query_eq=(('module', 'a'),)>") 342 | assert str(Q(module='a')) == "Query(module='a')" 343 | 344 | assert 'predicates.When: condition=, actions=('foo',)>" in repr(Q(module='a', action=C('foo'))) 346 | assert str(Q(module='a', action=C('foo'))) == "When(Query(module='a'), 'foo')" 347 | 348 | assert 'predicates.Not: predicate=>" in repr(~Q(module='a')) 350 | assert str(~Q(module='a')) == "Not(Query(module='a'))" 351 | 352 | assert 'predicates.Or: predicates=(, " in repr(Q(module='a') | Q(module='b')) 354 | assert repr(Q(module='a') | Q(module='b')).endswith("predicates.Query: query_eq=(('module', 'b'),)>)>") 355 | assert str(Q(module='a') | Q(module='b')) == "Or(Query(module='a'), Query(module='b'))" 356 | 357 | assert 'predicates.And: predicates=(," in repr(Q(module='a') & Q(module='b')) 359 | assert repr(Q(module='a') & Q(module='b')).endswith("predicates.Query: query_eq=(('module', 'b'),)>)>") 360 | assert str(Q(module='a') & Q(module='b')) == "And(Query(module='a'), Query(module='b'))" 361 | 362 | assert repr(From(module='a', depth_lte=2)).replace(', " 364 | "predicate=, watermark=0>" 365 | ) 366 | assert str(From(module='a', depth_gte=2)) == "From(Query(module='a'), Query(depth_gte=2), watermark=0)" 367 | 368 | assert ( 369 | repr(Backlog(module='a', action=CodePrinter, size=2)) 370 | .replace(', " 373 | 'size=2, stack=10, vars=False, action=CodePrinter' 374 | ) 375 | ) 376 | 377 | assert repr(Debugger()) == "Debugger(klass=, kwargs={})" 378 | assert str(Debugger()) == "Debugger(klass=, kwargs={})" 379 | 380 | assert repr(Manhole()) == 'Manhole(options={})' 381 | assert str(Manhole()) == 'Manhole(options={})' 382 | 383 | 384 | def test_q_deduplicate_callprinter(): 385 | out = repr(Q(CallPrinter(), action=CallPrinter())) 386 | assert out.startswith('CallPrinter(') 387 | 388 | 389 | def test_q_deduplicate_codeprinter(): 390 | out = repr(Q(CodePrinter(), action=CodePrinter())) 391 | assert out.startswith('CodePrinter(') 392 | 393 | 394 | def test_q_deduplicate_callprinter_cls(): 395 | out = repr(Q(CallPrinter(), action=CallPrinter)) 396 | assert out.startswith('CallPrinter(') 397 | 398 | 399 | def test_q_deduplicate_codeprinter_cls(): 400 | out = repr(Q(CodePrinter(), action=CodePrinter)) 401 | assert out.startswith('CodePrinter(') 402 | 403 | 404 | def test_q_deduplicate_callprinter_inverted(): 405 | out = repr(Q(CallPrinter(), action=CodePrinter())) 406 | assert out.startswith('CallPrinter(') 407 | 408 | 409 | def test_q_deduplicate_codeprinter_inverted(): 410 | out = repr(Q(CodePrinter(), action=CallPrinter())) 411 | assert out.startswith('CodePrinter(') 412 | 413 | 414 | def test_q_deduplicate_callprinter_cls_inverted(): 415 | out = repr(Q(CallPrinter(), action=CodePrinter)) 416 | assert out.startswith('CallPrinter(') 417 | 418 | 419 | def test_q_deduplicate_codeprinter_cls_inverted(): 420 | out = repr(Q(CodePrinter(), action=CallPrinter)) 421 | assert out.startswith('CodePrinter(') 422 | 423 | 424 | def test_q_action_callprinter(): 425 | out = repr(Q(action=CallPrinter())) 426 | assert 'condition=") 438 | 439 | 440 | def test_q_not_callable(): 441 | exc = pytest.raises(TypeError, Q, 'foobar') 442 | assert exc.value.args == ("Predicate 'foobar' is not callable.",) 443 | 444 | 445 | def test_q_expansion(): 446 | assert Q(C(1), C(2), module=3) == And(C(1), C(2), Q(module=3)) 447 | assert Q(C(1), C(2), module=3, action=C(4)) == When(And(C(1), C(2), Q(module=3)), C(4)) 448 | assert Q(C(1), C(2), module=3, actions=[C(4), C(5)]) == When(And(C(1), C(2), Q(module=3)), C(4), C(5)) 449 | -------------------------------------------------------------------------------- /tests/test_remote.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import signal 3 | import sys 4 | from shutil import which 5 | 6 | import pytest 7 | from process_tests import TestProcess 8 | from process_tests import dump_on_error 9 | from process_tests import wait_for_strings 10 | 11 | from utils import TIMEOUT 12 | 13 | platform.system() 14 | 15 | 16 | @pytest.mark.skipif('platform.system() == "Windows"') 17 | def test_manhole(): 18 | with TestProcess('python', '-msamplemanhole') as target, dump_on_error(target.read): 19 | wait_for_strings(target.read, TIMEOUT, 'Oneshot activation is done by signal') 20 | 21 | with TestProcess('hunter-trace', '-p', str(target.proc.pid), 'stdlib=False') as tracer, dump_on_error(tracer.read): 22 | wait_for_strings( 23 | tracer.read, 24 | TIMEOUT, 25 | 'Output stream active. Starting tracer', 26 | 'call => stuff()', 27 | 'line time.sleep(1)', 28 | 'return <= stuff: None', 29 | ) 30 | wait_for_strings(target.read, TIMEOUT, 'Broken pipe', 'Stopping tracer.') 31 | 32 | 33 | @pytest.mark.skipif('platform.system() == "Windows"') 34 | def test_manhole_reattach(): 35 | with TestProcess('python', '-msamplemanhole') as target, dump_on_error(target.read): 36 | wait_for_strings(target.read, TIMEOUT, 'Oneshot activation is done by signal') 37 | 38 | with TestProcess('hunter-trace', '-p', str(target.proc.pid), 'stdlib=False') as tracer, dump_on_error(tracer.read): 39 | wait_for_strings( 40 | tracer.read, 41 | TIMEOUT, 42 | 'Output stream active. Starting tracer', 43 | 'call => stuff()', 44 | 'line time.sleep(1)', 45 | 'return <= stuff: None', 46 | ) 47 | tracer.proc.send_signal(signal.SIGINT) 48 | 49 | with TestProcess('hunter-trace', '-p', str(target.proc.pid), 'stdlib=False') as tracer, dump_on_error(tracer.read): 50 | wait_for_strings( 51 | tracer.read, 52 | TIMEOUT, 53 | 'Output stream active. Starting tracer', 54 | ' => stuff()', 55 | ' time.sleep(1)', 56 | ' <= stuff: None', 57 | ) 58 | 59 | wait_for_strings(target.read, TIMEOUT, 'Broken pipe', 'Stopping tracer.') 60 | 61 | 62 | @pytest.mark.skipif('platform.system() == "Windows"') 63 | def test_manhole_clean_exit(): 64 | with TestProcess('python', '-msamplemanhole') as target, dump_on_error(target.read): 65 | wait_for_strings(target.read, TIMEOUT, 'Oneshot activation is done by signal') 66 | 67 | with TestProcess('hunter-trace', '-p', str(target.proc.pid), 'stdlib=False') as tracer, dump_on_error(tracer.read): 68 | wait_for_strings( 69 | tracer.read, 70 | TIMEOUT, 71 | 'Output stream active. Starting tracer', 72 | 'call => stuff()', 73 | 'line time.sleep(1)', 74 | 'return <= stuff: None', 75 | ) 76 | target.buff.reset() 77 | tracer.proc.send_signal(signal.SIGINT) 78 | wait_for_strings( 79 | target.read, 80 | TIMEOUT, 81 | 'remote.deactivate()', 82 | 'Doing stuff', 83 | 'Doing stuff', 84 | 'Doing stuff', 85 | ) 86 | 87 | 88 | @pytest.mark.skipif('platform.system() == "Windows"') 89 | @pytest.mark.skipif('platform.machine() == "aarch64"') 90 | @pytest.mark.skipif('platform.python_implementation() == "PyPy"') 91 | @pytest.mark.skipif('not which("gdb")') 92 | def test_gdb(): 93 | with TestProcess('python', '-msamplemanhole') as target, dump_on_error(target.read): 94 | with TestProcess('hunter-trace', '-p', str(target.proc.pid), '--gdb', 'stdlib=False') as tracer, dump_on_error(tracer.read): 95 | wait_for_strings( 96 | tracer.read, 97 | TIMEOUT, 98 | 'WARNING: Using GDB may deadlock the process or create unpredictable results!', 99 | 'Output stream active. Starting tracer', 100 | 'call => stuff()', 101 | 'line time.sleep(1)', 102 | 'return <= stuff: None', 103 | ) 104 | wait_for_strings(target.read, TIMEOUT, 'Broken pipe', 'Stopping tracer.') 105 | 106 | 107 | @pytest.mark.skipif('platform.system() == "Windows"') 108 | @pytest.mark.skipif('platform.machine() == "aarch64"') 109 | @pytest.mark.skipif('platform.python_implementation() == "PyPy"') 110 | @pytest.mark.skipif('not which("gdb")') 111 | def test_gdb_clean_exit(): 112 | with TestProcess(sys.executable, '-msamplemanhole') as target, dump_on_error(target.read): 113 | with TestProcess('hunter-trace', '-p', str(target.proc.pid), 'stdlib=False', '--gdb') as tracer, dump_on_error(tracer.read): 114 | wait_for_strings( 115 | tracer.read, 116 | TIMEOUT, 117 | 'WARNING: Using GDB may deadlock the process or create unpredictable results!', 118 | 'Output stream active. Starting tracer', 119 | 'call => stuff()', 120 | 'line time.sleep(1)', 121 | 'return <= stuff: None', 122 | ) 123 | target.buff.reset() 124 | tracer.proc.send_signal(signal.SIGINT) 125 | wait_for_strings(target.read, TIMEOUT, 'Doing stuff', 'Doing stuff', 'Doing stuff') 126 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import re 2 | from array import array 3 | from collections import OrderedDict 4 | from collections import deque 5 | from collections import namedtuple 6 | from datetime import date 7 | from datetime import datetime 8 | from datetime import time 9 | from datetime import timedelta 10 | from datetime import tzinfo 11 | from decimal import Decimal 12 | from socket import _socket 13 | from socket import socket 14 | 15 | import py 16 | import pytest 17 | import six 18 | 19 | from hunter.util import safe_repr 20 | 21 | try: 22 | from inspect import getattr_static 23 | except ImportError: 24 | from hunter.backports.inspect import getattr_static 25 | 26 | MyTuple = namedtuple('MyTuple', 'a b') 27 | 28 | 29 | class Dict(dict): 30 | pass 31 | 32 | 33 | class List(list): 34 | pass 35 | 36 | 37 | class Set(set): 38 | pass 39 | 40 | 41 | Stuff = namedtuple('Stuff', 'a b') 42 | 43 | 44 | class Foobar: 45 | __slots__ = () 46 | __repr__ = lambda _: 'Foo-bar' 47 | 48 | 49 | class Bad1: 50 | def __repr__(self): 51 | raise Exception('Bad!') 52 | 53 | def method(self): 54 | pass 55 | 56 | 57 | class String(str): 58 | def __repr__(self): 59 | raise Exception('Bad!') 60 | 61 | __str__ = __repr__ 62 | 63 | 64 | class Int(int): 65 | def __repr__(self): 66 | raise Exception('Bad!') 67 | 68 | __str__ = __repr__ 69 | 70 | 71 | class TzInfo(tzinfo): 72 | def __repr__(self): 73 | raise Exception('Bad!') 74 | 75 | __str__ = __repr__ 76 | 77 | 78 | class Bad2: 79 | def __repr__(self): 80 | raise Exception('Bad!') 81 | 82 | def method(self): 83 | pass 84 | 85 | 86 | def test_safe_repr(): 87 | s1 = _socket.socket() 88 | s2 = socket() 89 | data = { 90 | 'a': [set('b')], 91 | ('c',): deque(['d']), 92 | 'e': s1, 93 | 1: array('d', [1, 2]), 94 | frozenset('f'): s2, 95 | 'g': Dict( 96 | { 97 | 'a': List('123'), 98 | 'b': Set([Decimal('1.0')]), 99 | 'c': Stuff(1, 2), 100 | 'd': Exception( 101 | 1, 102 | 2, 103 | { 104 | 'a': safe_repr, 105 | 'b': Foobar, 106 | 'c': Bad2(), 107 | 'ct': Bad2, 108 | }, 109 | ), 110 | } 111 | ), 112 | 'od': OrderedDict({'a': 'b'}), 113 | 'nt': MyTuple(1, 2), 114 | 'bad1': Bad1().method, 115 | 'bad2': Bad2().method, 116 | 'regex': re.compile('123', 0), 117 | 'badregex': re.compile(String('123')), 118 | 'badregex2': re.compile(String('123'), Int(re.IGNORECASE)), 119 | 'date': date(Int(2000), Int(1), Int(2)), 120 | 'datetime': datetime( 121 | Int(2000), 122 | Int(1), 123 | Int(2), 124 | Int(3), 125 | Int(4), 126 | Int(5), 127 | Int(600), 128 | tzinfo=TzInfo(), 129 | ), 130 | 'time': time(Int(3), Int(4), Int(5), Int(600), tzinfo=TzInfo()), 131 | 'timedelta': timedelta(Int(1), Int(2), Int(3), Int(4), Int(5), Int(6), Int(7)), 132 | } 133 | print(re.compile(String('123'), Int(re.IGNORECASE)).match('123')) 134 | print(safe_repr(data)) 135 | print(safe_repr([data])) 136 | print(safe_repr([[data]])) 137 | print(safe_repr([[[data]]])) 138 | print(safe_repr([[[[data]]]])) 139 | print(safe_repr([[[[[data]]]]])) 140 | 141 | s1.close() 142 | s2.close() 143 | assert safe_repr(py.io).startswith('