├── .github └── workflows │ ├── build.yaml │ └── validate.yaml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.txt ├── Makefile ├── README.md ├── pyproject.toml ├── requirements-dev.txt ├── src └── pytest_pystack │ ├── __init__.py │ ├── _config.py │ ├── _debug_detect.py │ ├── _monitor_process.py │ └── plugin.py └── tests ├── conftest.py └── test_pytest_pystack.py /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | types: 8 | - published 9 | schedule: 10 | # At 12:00 UTC on every day-of-month 11 | - cron: "0 12 */1 * *" 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build_dists: 19 | name: Source and wheel distributions 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Build distributions 25 | run: pipx run build[virtualenv] --sdist --wheel 26 | 27 | - uses: actions/upload-artifact@v4 28 | with: 29 | name: dist 30 | path: dist/* 31 | 32 | - uses: actions/upload-artifact@v4 33 | with: 34 | name: test-deps 35 | path: | 36 | tests/ 37 | Makefile 38 | requirements-dev.txt 39 | 40 | test: 41 | needs: [build_dists] 42 | name: 'Test' 43 | runs-on: ubuntu-latest 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 48 | steps: 49 | - name: Set up Python 50 | uses: actions/setup-python@v4 51 | with: 52 | python-version: ${{ matrix.python_version }}-dev 53 | - uses: actions/download-artifact@v4 54 | with: 55 | name: dist 56 | path: dist 57 | - uses: actions/download-artifact@v4 58 | with: 59 | name: test-deps 60 | path: . 61 | - name: Install wheel 62 | run: | 63 | python -m pip install dist/*.whl 64 | - name: Install Python dependencies 65 | run: | 66 | python -m pip install -r requirements-dev.txt 67 | - name: Disable ptrace security restrictions 68 | run: | 69 | echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope 70 | - name: Test 71 | run: | 72 | make check 73 | 74 | upload_pypi: 75 | needs: [test] 76 | runs-on: ubuntu-latest 77 | if: github.event_name == 'release' && github.event.action == 'published' 78 | steps: 79 | - uses: actions/download-artifact@v4 80 | with: 81 | name: dist 82 | path: dist 83 | 84 | - uses: pypa/gh-action-pypi-publish@v1.8.11 85 | with: 86 | password: ${{ secrets.PYPI_PASSWORD }} 87 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | lint: 14 | name: 'Lint' 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.12-dev' 22 | - name: Install Python dependencies 23 | run: | 24 | python3 -m pip install -r requirements-dev.txt 25 | - name: Install Package 26 | run: | 27 | python3 -m pip install -e . 28 | - name: Lint sources 29 | run: | 30 | make PYTHON=python${{matrix.python_version}} lint 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE stuff 2 | 3 | .idea/* 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .nox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | *.cover 40 | *.py,cover 41 | .hypothesis/ 42 | .pytest_cache/ 43 | cover/ 44 | 45 | # Translations 46 | *.mo 47 | *.pot 48 | 49 | # Sphinx documentation 50 | docs/_build/ 51 | 52 | 53 | # Environments 54 | .env 55 | .venv 56 | env/ 57 | venv/ 58 | ENV/ 59 | env.bak/ 60 | venv.bak/ 61 | 62 | # mypy 63 | .mypy_cache/ 64 | .dmypy.json 65 | dmypy.json 66 | 67 | # VSCode 68 | .devcontainer 69 | .vscode 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Before contributing to this repository, first please discuss the change you wish to make via an 4 | issue, or any other method of communication with the maintainers of this repository. 5 | 6 | 7 | ## Contribution Licensing 8 | 9 | Since this project is distributed under the terms of an [open source license](LICENSE), 10 | contributions that you make are licensed under the same terms. For us to be able to accept your 11 | contributions, we will need explicit confirmation from you that you are able and willing to provide 12 | them under these terms, and the mechanism we use to do this is called a Developer's Certificate of 13 | Origin [(DCO)](https://github.com/bloomberg/.github/blob/main/DCO.md). This is similar to the 14 | process used by the Linux kernel, Samba, and many other major open source projects. 15 | 16 | To participate under these terms, all that you must do is include a line like the following as the 17 | last line of the commit message for each commit in your contribution: 18 | 19 | Signed-Off-By: Random J. Developer 20 | 21 | The simplest way to accomplish this is to add `-s` or `--signoff` to your `git commit` command. 22 | 23 | You must use your real name (sorry, no pseudonyms, and no anonymous contributions). 24 | 25 | ## Tests 26 | 27 | Changes should always include tests. If this is a bug fix it is a good idea to add the tests as the 28 | first commit of the pull request and the changes to fix the issue in subsequent commits to make it 29 | easier to validate it. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2022 Bloomberg LP 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON ?= python 2 | 3 | SRCDIR := src 4 | python_files := $(shell find * -name \*.py -not -path '*/\.*') 5 | 6 | .PHONY: check 7 | check: 8 | $(PYTHON) -m pytest -vv tests/ 9 | 10 | .PHONY: format 11 | format: 12 | $(PYTHON) -m isort $(python_files) 13 | $(PYTHON) -m black $(python_files) 14 | 15 | .PHONY: lint 16 | lint: 17 | $(PYTHON) -m ruff check $(python_files) 18 | $(PYTHON) -m isort --check $(python_files) 19 | $(PYTHON) -m black --check $(python_files) 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # pytest-pystack 6 | 7 | [![CI](https://github.com/bloomberg/pytest-pystack/actions/workflows/validate.yaml/badge.svg)](https://github.com/bloomberg/pytest-pystack/actions/workflows/validate.yaml) 8 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-pystack) 9 | ![PyPI](https://img.shields.io/pypi/v/pytest-pystack) 10 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/pytest-pystack) 11 | ![Code Style](https://img.shields.io/badge/code%20style-black,%20isort-000000.svg) 12 | 13 | A pytest plug-in for easy integration of PyStack in your test suite. 14 | 15 | It can be used to automatically dump the stack trace of a hanging test in your suite (with exception to test using `pytester` fixture). 16 | 17 | See [PyStack](https://github.com/bloomberg/pystack) for further information about the tool. 18 | 19 | ## Installation 20 | 21 | To install the PyStack pytest plug-in, just run the following command in your venv: 22 | 23 | `python -m pip install pytest-pystack` 24 | 25 | ## Quick Start 26 | 27 | After you have installed the pytest plug-in, you can have PyStack monitor your test suite and output a stack trace if a test takes more than 5 28 | seconds, simply by running pytest with argument `--pystack-threshold=5`. 29 | 30 | ## Configuration 31 | 32 | The PyStack plug-in can be configured via the command line with the following options: 33 | 34 | - `--pystack-threshold`: Enables the plug-in and monitors all tests, 35 | generating a stack trace if they take longer than the specified 36 | threshold. Note, this neither stops nor fails the test case after the specified threshold. 37 | - `--pystack-output-file`: Appends PyStack output to a file. 38 | - `--pystack-path`: Path to the `pystack` executable. 39 | - `--pystack-args`: Additional args to pass to `pystack remote `, 40 | like `--native` or `--native-all`. 41 | 42 | And through any pytest config file, see an example of `pyproject.toml`: 43 | 44 | ```toml 45 | [tool.pytest.ini_options] 46 | pystack_threshold=60 47 | pystack_path="custom-version-of-pystack" 48 | pystack_output_file="./pystack.log" 49 | pystack_args="--native" 50 | ``` 51 | 52 | # License 53 | 54 | This project is Apache-2.0 licensed, as found in the [LICENSE](LICENSE.txt) file. 55 | 56 | # Code of Conduct 57 | 58 | - [Code of Conduct](https://github.com/bloomberg/.github/blob/main/CODE_OF_CONDUCT.md) 59 | 60 | This project has adopted a Code of Conduct. If you have any concerns about the Code, or behavior 61 | that you have experienced in the project, please contact us at opensource@bloomberg.net. 62 | 63 | # Contributing 64 | 65 | We welcome your contributions to help us improve and extend this project! 66 | 67 | Below you will find some basic steps required to be able to contribute to the project. If you have 68 | any questions about this process or any other aspect of contributing to a Bloomberg open source 69 | project, feel free to send an email to opensource@bloomberg.net and we'll get your questions 70 | answered as quickly as we can. 71 | 72 | ## Contribution Licensing 73 | 74 | Since this project is distributed under the terms of an [open source license](LICENSE.txt), 75 | contributions that you make are licensed under the same terms. For us to be able to accept your 76 | contributions, we will need explicit confirmation from you that you are able and willing to provide 77 | them under these terms, and the mechanism we use to do this is called a Developer's Certificate of 78 | Origin [(DCO)](https://github.com/bloomberg/.github/blob/main/DCO.md). This is similar to the 79 | process used by the Linux kernel, Samba, and many other major open source projects. 80 | 81 | To participate under these terms, all that you must do is include a line like the following as the 82 | last line of the commit message for each commit in your contribution: 83 | 84 | ``` 85 | Signed-Off-By: Random J. Developer 86 | ``` 87 | 88 | The simplest way to accomplish this is to add `-s` or `--signoff` to your `git commit` command. 89 | 90 | You must use your real name (sorry, no pseudonyms, and no anonymous contributions). 91 | 92 | ## Steps 93 | 94 | - Create an Issue, select 'Feature Request', and explain the proposed change. 95 | - Follow the guidelines in the issue template presented to you. 96 | - Submit the Issue. 97 | - Submit a Pull Request and link it to the Issue by including "#" in the Pull Request 98 | summary. 99 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=63.0.0", 4 | ] 5 | build-backend = 'setuptools.build_meta' 6 | 7 | [project] 8 | name = "pytest-pystack" 9 | version = "1.0.2" 10 | description = "Plugin to run pystack after a timeout for a test suite." 11 | requires-python = ">=3.8" 12 | dependencies = [ 13 | "pytest>=3.5.0", 14 | "pystack", 15 | ] 16 | readme = "README.md" 17 | license = {file = "LICENSE.txt"} 18 | keywords = ["pytest", "debugging", "hung", "pystack", "bloomberg"] 19 | classifiers = [ 20 | "Intended Audience :: Developers", 21 | "Operating System :: POSIX :: Linux", 22 | "Topic :: Software Development :: Debuggers", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Framework :: Pytest", 29 | ] 30 | 31 | [[project.authors]] 32 | name = "Bloomberg LP" 33 | email = "mcorcherojim@bloomberg.net" 34 | 35 | [project.entry-points.pytest11] 36 | pystack = "pytest_pystack.plugin" 37 | 38 | 39 | [tool.isort] 40 | force_single_line = true 41 | multi_line_output = 3 42 | include_trailing_comma = true 43 | force_grid_wrap = 0 44 | use_parentheses = true 45 | line_length = 88 46 | known_first_party=["pytest_pystack"] 47 | known_third_party=["pytest"] 48 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | furo 3 | isort 4 | pytest 5 | pytest-cov 6 | pytest-xdist 7 | ruff -------------------------------------------------------------------------------- /src/pytest_pystack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/pytest-pystack/b4e78d29e5e84e09a56ea8c1a93b881c2cf85c3d/src/pytest_pystack/__init__.py -------------------------------------------------------------------------------- /src/pytest_pystack/_config.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Optional 3 | 4 | 5 | @dataclasses.dataclass 6 | class PystackConfig: 7 | threshold: float 8 | pystack_path: str 9 | output_file: Optional[str] = None 10 | print_stderr: bool = True 11 | pystack_args: Optional[str] = None 12 | -------------------------------------------------------------------------------- /src/pytest_pystack/_debug_detect.py: -------------------------------------------------------------------------------- 1 | import inspect # used for introspection of module name 2 | import multiprocessing 3 | import sys 4 | 5 | debug_detected = multiprocessing.Event() 6 | # bdb covers pdb, ipdb, and possibly others 7 | # pydevd covers PyCharm, VSCode, and possibly others 8 | KNOWN_DEBUGGING_MODULES = {"pydevd", "bdb", "pydevd_frame_evaluator"} 9 | 10 | 11 | # Replace settrace to record the tracefunc 12 | original_settrace = sys.settrace 13 | 14 | 15 | def fake_settrace(tracefunc): 16 | if tracefunc and is_debugging(tracefunc): 17 | debug_detected.set() 18 | original_settrace(tracefunc) 19 | 20 | 21 | sys.settrace = fake_settrace 22 | 23 | 24 | def is_debugging(tracefunc): 25 | """Detect if a debugging session is in progress. 26 | This looks at both pytest's builtin pdb support as well as 27 | externally installed debuggers using some heuristics. 28 | This is done by checking if the module that is the origin 29 | of the trace function is in KNOWN_DEBUGGING_MODULES. 30 | """ 31 | global KNOWN_DEBUGGING_MODULES 32 | if tracefunc and inspect.getmodule(tracefunc): 33 | parts = inspect.getmodule(tracefunc).__name__.split(".") 34 | for name in KNOWN_DEBUGGING_MODULES: 35 | if any(part.startswith(name) for part in parts): 36 | return True 37 | return False 38 | -------------------------------------------------------------------------------- /src/pytest_pystack/_monitor_process.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import os 3 | import shlex 4 | import subprocess 5 | import sys 6 | from queue import Empty 7 | 8 | from ._config import PystackConfig 9 | from ._debug_detect import debug_detected 10 | 11 | _queue = multiprocessing.Queue() 12 | _process = None 13 | 14 | 15 | def start(config): 16 | global _process 17 | _process = multiprocessing.Process( 18 | target=_run_monitor, 19 | args=( 20 | config, 21 | os.getpid(), 22 | _queue, 23 | ), 24 | name="pystack_monitor", 25 | ) 26 | _process.start() 27 | return _queue 28 | 29 | 30 | def stop(): 31 | if _process: 32 | _queue.put_nowait(None) 33 | _process.join(timeout=5) 34 | if _process.is_alive(): 35 | _process.kill() 36 | 37 | 38 | def _run_monitor(config: PystackConfig, pid, queue): 39 | pystack_cmd = [ 40 | config.pystack_path, 41 | "remote", 42 | ] 43 | if config.pystack_args: 44 | pystack_cmd += shlex.split(config.pystack_args) 45 | handled_test_cases = set() 46 | while True: 47 | testcase = queue.get() 48 | if testcase is None: 49 | break 50 | 51 | if testcase in handled_test_cases: 52 | continue 53 | handled_test_cases.add(testcase) 54 | 55 | try: 56 | new_testcase = queue.get(timeout=config.threshold) 57 | if new_testcase != testcase: 58 | print( 59 | f"new test {new_testcase} should not start before previous {testcase} test finished", 60 | file=sys.__stderr__, 61 | ) 62 | raise Exception( 63 | "new test should not start before previous test finished" 64 | ) 65 | except Empty: 66 | output = "" 67 | output += f"\n\n**** PYSTACK -- {testcase} ***\n" 68 | output += f"Timed out waiting for process {pid} to finish {testcase}:" 69 | proc = subprocess.run( 70 | [*pystack_cmd, str(pid)], 71 | stdout=subprocess.PIPE, 72 | text=True, 73 | ) 74 | output += proc.stdout 75 | output += "**** PYSTACK ***\n" 76 | is_debug = debug_detected.is_set() 77 | debug_detected.clear() 78 | if config.print_stderr and not is_debug: 79 | print(output, file=sys.__stderr__) 80 | if config.output_file: 81 | with open(config.output_file, "a") as f: 82 | print(output, file=f) 83 | -------------------------------------------------------------------------------- /src/pytest_pystack/plugin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from . import _config 4 | from . import _monitor_process 5 | from ._debug_detect import debug_detected 6 | 7 | 8 | @pytest.hookimpl 9 | def pytest_enter_pdb(): # This needs to be in the plugin.py file to work 10 | """Prevents running pystack when we entered pdb. 11 | 12 | This disables the plugin logic when pytest's builtin pdb 13 | support notices we entered pdb. 14 | """ 15 | # Since pdb.set_trace happens outside of any pytest control, we don't have 16 | # any pytest ``item`` here, so we cannot use timeout_teardown. Thus, we 17 | # need another way to signify that pystack should not be run. 18 | debug_detected.set() 19 | 20 | 21 | def pytest_addoption(parser) -> None: 22 | group = parser.getgroup("pystack") 23 | threshold_help = "Generate a pystack report after a threshold in seconds." 24 | group.addoption( 25 | "--pystack-threshold", 26 | type=float, 27 | required=False, 28 | help=threshold_help, 29 | ) 30 | parser.addini("pystack_threshold", threshold_help) 31 | 32 | output_file_help = "Output file. Results will be appended." 33 | group.addoption( 34 | "--pystack-output-file", 35 | type=str, 36 | required=False, 37 | help=output_file_help, 38 | ) 39 | parser.addini("pystack_output_file", output_file_help) 40 | 41 | pystack_path_help = "Path to the pystack executable." 42 | group.addoption( 43 | "--pystack-path", 44 | type=str, 45 | required=False, 46 | default="pystack", 47 | help=pystack_path_help, 48 | ) 49 | parser.addini("pystack_path", pystack_path_help) 50 | 51 | pystack_args_help = ( 52 | "String with additional args to pass to 'pystack remote'. E.g: '--native'." 53 | ) 54 | group.addoption( 55 | "--pystack-args", 56 | type=str, 57 | required=False, 58 | help=pystack_args_help, 59 | ) 60 | parser.addini("pystack_args", pystack_args_help) 61 | 62 | 63 | @pytest.hookimpl 64 | def pytest_runtest_makereport(item, call): 65 | if ( 66 | call.when in {"setup", "teardown"} 67 | and item.config._pystack_queue 68 | and "pytester" not in item.fixturenames 69 | ): 70 | item.config._pystack_queue.put(item.name) 71 | 72 | 73 | def _get_cli_or_file_value(pytest_config, key): 74 | ret = pytest_config.getoption(key) 75 | if ret is not None: 76 | return ret 77 | return pytest_config.getini(key) 78 | 79 | 80 | @pytest.hookimpl 81 | def pytest_configure(config) -> None: 82 | config._pystack_queue = None 83 | config._pystack_config = None 84 | threshold = _get_cli_or_file_value(config, "pystack_threshold") 85 | if not threshold: 86 | return # not configured 87 | 88 | output_file = _get_cli_or_file_value(config, "pystack_output_file") 89 | pystack_args = _get_cli_or_file_value(config, "pystack_args") 90 | pystack_path = _get_cli_or_file_value(config, "pystack_path") 91 | 92 | pystack_config = _config.PystackConfig( 93 | threshold=float(threshold), 94 | output_file=output_file or None, 95 | pystack_path=pystack_path, 96 | pystack_args=pystack_args or None, 97 | print_stderr=True, 98 | ) 99 | config._pystack_queue = _monitor_process.start(pystack_config) 100 | 101 | 102 | @pytest.hookimpl 103 | def pytest_sessionfinish(session, exitstatus): 104 | _monitor_process.stop() 105 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = "pytester" 2 | -------------------------------------------------------------------------------- /tests/test_pytest_pystack.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import textwrap 4 | 5 | import pytest 6 | 7 | SLEEPING_TEST_TEMPLATE = """ 8 | import time 9 | def test_sleeping_test(): 10 | print("start") 11 | time.sleep({sleep_seconds}) 12 | print("finished") 13 | """ 14 | 15 | DEBUGGING_TEST = """ 16 | def test_debugging_test(): 17 | print("start") 18 | breakpoint() 19 | print("finished") 20 | """ 21 | 22 | NON_DEBUGGING_TEST = """ 23 | import time 24 | def test_non_debugging_test(): 25 | print("start") 26 | time.sleep(5) 27 | print("finished") 28 | """ 29 | 30 | SKIPPED_TEST = """ 31 | import pytest 32 | @pytest.mark.skip 33 | def test_to_skip(): 34 | pass 35 | """ 36 | 37 | ERROR_TEST = """ 38 | def test_error(doesnt_exist): 39 | pass 40 | """ 41 | 42 | 43 | def USER_INPUT_GENERATOR(nb_inputs=1): 44 | result = ["import time"] 45 | result += ["time.sleep(5)", "print('continue')"] * nb_inputs 46 | return "\n".join(result) 47 | 48 | 49 | TEST_FAILING_AFTER_TIMEOUT = """ 50 | import time 51 | def test_failing_after_timeout(): 52 | time.sleep(5) 53 | assert False 54 | """ 55 | 56 | TEST_FAILING_BEFORE_TIMEOUT = """ 57 | import time 58 | def test_failing_before_timeout(): 59 | assert False 60 | time.sleep(5) 61 | """ 62 | 63 | SLEEPING_TEST_1S = SLEEPING_TEST_TEMPLATE.format(sleep_seconds=1) 64 | SLEEPING_TEST_5S = SLEEPING_TEST_TEMPLATE.format(sleep_seconds=5) 65 | 66 | USER_INPUT_GENERATOR_1 = USER_INPUT_GENERATOR(nb_inputs=1) 67 | USER_INPUT_GENERATOR_2 = USER_INPUT_GENERATOR(nb_inputs=2) 68 | 69 | 70 | def test_default_pystack_options(testdir, monkeypatch, capfd): 71 | monkeypatch.chdir(testdir.tmpdir) 72 | testdir.makepyfile(SLEEPING_TEST_5S) 73 | 74 | testdir.runpytest("--pystack-threshold=1", "-s") 75 | 76 | # Outputs to stderr 77 | _, stderr = capfd.readouterr() 78 | assert "PYSTACK -- test_sleeping_test" in stderr 79 | assert "time.sleep(5)" in stderr 80 | 81 | 82 | def serialize_config(pystack_config): 83 | return textwrap.dedent( 84 | f""" 85 | [tool.pytest.ini_options] 86 | {pystack_config} 87 | """ 88 | ) 89 | 90 | 91 | def test_silent_when_debugging_by_default(testdir, monkeypatch): 92 | monkeypatch.chdir(testdir.tmpdir) 93 | testdir.makepyfile(DEBUGGING_TEST) 94 | 95 | # Simulating user interaction taking a long time 96 | user_process = subprocess.Popen( 97 | [sys.executable, "-c", USER_INPUT_GENERATOR_1], 98 | stdout=subprocess.PIPE, 99 | text=True, 100 | ) 101 | pytest_process = subprocess.Popen( 102 | ["pytest", "--pystack-threshold=1", "-s"], 103 | stdin=user_process.stdout, 104 | stdout=subprocess.PIPE, 105 | stderr=subprocess.PIPE, 106 | text=True, 107 | ) 108 | user_process.wait() 109 | pytest_process.wait() 110 | stderr = pytest_process.stderr.read() 111 | 112 | # Does *NOT* output to stderr 113 | assert not stderr 114 | 115 | 116 | def test_silent_only_in_debugging_tests(testdir, monkeypatch): 117 | monkeypatch.chdir(testdir.tmpdir) 118 | 119 | # The order is important here: test_debugging should be first and 120 | # test_non_debugging should be second, so that we test that the debugging 121 | # test didn't suppress the output of the second test. 122 | testdir.makepyfile( 123 | test_debugging=DEBUGGING_TEST, 124 | test_non_debugging=NON_DEBUGGING_TEST, 125 | ) 126 | 127 | # Simulating user interaction taking a long time 128 | user_process = subprocess.Popen( 129 | [sys.executable, "-c", USER_INPUT_GENERATOR_1], 130 | stdout=subprocess.PIPE, 131 | text=True, 132 | ) 133 | pytest_process = subprocess.Popen( 134 | ["pytest", "--pystack-threshold=1", "-s"], 135 | stdin=user_process.stdout, 136 | stdout=subprocess.PIPE, 137 | stderr=subprocess.PIPE, 138 | text=True, 139 | ) 140 | user_process.wait() 141 | pytest_process.wait() 142 | stderr = pytest_process.stderr.read() 143 | 144 | # Only the non debugging test should have a pystack output 145 | assert "PYSTACK -- test_debugging_test" not in stderr 146 | assert "PYSTACK -- test_non_debugging_test" in stderr 147 | assert stderr.count("time.sleep(5)") == 1 148 | 149 | 150 | def test_silent_in_debugged_tests_failing_before_timeout(testdir, monkeypatch): 151 | monkeypatch.chdir(testdir.tmpdir) 152 | 153 | # TODO is the order important here? 154 | testdir.makepyfile(TEST_FAILING_BEFORE_TIMEOUT + TEST_FAILING_AFTER_TIMEOUT) 155 | 156 | # Simulating user interaction taking a long time 157 | user_process = subprocess.Popen( 158 | [sys.executable, "-c", USER_INPUT_GENERATOR_2], 159 | stdout=subprocess.PIPE, 160 | text=True, 161 | ) 162 | pytest_process = subprocess.Popen( 163 | ["pytest", "--pystack-threshold=1", "--pdb", "-s"], 164 | stdin=user_process.stdout, 165 | stdout=subprocess.PIPE, 166 | stderr=subprocess.PIPE, 167 | text=True, 168 | ) 169 | user_process.wait() 170 | pytest_process.wait() 171 | stderr = pytest_process.stderr.read() 172 | 173 | # Only the non debugging test should have a pystack output 174 | assert "PYSTACK -- test_failing_before_timeout" not in stderr 175 | assert "PYSTACK -- test_failing_after_timeout" in stderr 176 | assert stderr.count("time.sleep(5)") == 1 177 | 178 | 179 | @pytest.mark.parametrize( 180 | ["pytestarg", "pytestconfig"], 181 | [ 182 | ("--pystack-threshold=2", ""), # configured in CLI 183 | ("", "pystack_threshold=2"), # configured in config file 184 | ("--pystack-threshold=2", "pystack_threshold=10"), # CLI takes preference 185 | ], 186 | ) 187 | @pytest.mark.parametrize( 188 | "pystack_is_triggered", [True, False], ids=["Triggered", "Not triggered"] 189 | ) 190 | def test_threshold_option( 191 | testdir, monkeypatch, capfd, pytestarg, pytestconfig, pystack_is_triggered 192 | ): 193 | monkeypatch.chdir(testdir.tmpdir) 194 | test_file = SLEEPING_TEST_5S if pystack_is_triggered else SLEEPING_TEST_1S 195 | testdir.makepyfile(test_file) 196 | 197 | config_file = testdir.makepyprojecttoml(serialize_config(pytestconfig)) 198 | testdir.runpytest(pytestarg, "-s", f"-c={config_file}") 199 | 200 | # Outputs to stderr 201 | _, stderr = capfd.readouterr() 202 | if pystack_is_triggered: 203 | assert "PYSTACK -- test_sleeping_test" in stderr 204 | assert "time.sleep(5)" in stderr 205 | else: 206 | assert not stderr 207 | 208 | 209 | @pytest.mark.parametrize( 210 | ["pytestarg", "pytestconfig"], 211 | [ 212 | ("--pystack-output-file=./pystack_output.log", ""), # configured in CLI 213 | ("", "pystack_output_file='./pystack_output.log'"), # configured in config file 214 | ], 215 | ) 216 | def test_output_file_option(testdir, monkeypatch, pytestarg, pytestconfig): 217 | monkeypatch.chdir(testdir.tmpdir) 218 | testdir.makepyfile(SLEEPING_TEST_5S) 219 | 220 | config_file = testdir.makepyprojecttoml(serialize_config(pytestconfig)) 221 | testdir.runpytest("--pystack-threshold=1", pytestarg, "-s", f"-c={config_file}") 222 | 223 | # Outputs to stderr 224 | with open("./pystack_output.log") as f: 225 | file_content = f.read() 226 | assert "PYSTACK -- test_sleeping_test" in file_content 227 | assert "time.sleep(5)" in file_content 228 | 229 | 230 | @pytest.mark.parametrize( 231 | ["pytestarg", "pytestconfig"], 232 | [ 233 | ("--pystack-args='--native'", ""), # configured in CLI 234 | ("", "pystack_args='--native'"), # configured in config file 235 | ], 236 | ) 237 | def test_pystack_args(testdir, monkeypatch, pytestarg, pytestconfig, capfd): 238 | monkeypatch.chdir(testdir.tmpdir) 239 | testdir.makepyfile(SLEEPING_TEST_5S) 240 | 241 | config_file = testdir.makepyprojecttoml(serialize_config(pytestconfig)) 242 | testdir.runpytest("--pystack-threshold=1", pytestarg, "-s", f"-c={config_file}") 243 | 244 | # Outputs to stderr 245 | _, stderr = capfd.readouterr() 246 | assert "PYSTACK -- test_sleeping_test" in stderr 247 | assert "(C) File " in stderr 248 | assert "time.sleep(5)" in stderr 249 | 250 | 251 | @pytest.mark.parametrize("test_code", [SKIPPED_TEST, ERROR_TEST]) 252 | def test_both_setup_and_teardown_called_for_all_tests(testdir, monkeypatch, test_code): 253 | # GIVEN 254 | monkeypatch.chdir(testdir.tmpdir) 255 | testdir.makepyfile(test_code) 256 | 257 | # WHEN 258 | pytest_process = subprocess.Popen( 259 | ["pytest", "--pystack-threshold=1", "-s"], 260 | stdout=subprocess.PIPE, 261 | stderr=subprocess.PIPE, 262 | text=True, 263 | ) 264 | pytest_process.wait() 265 | stderr = pytest_process.stderr.read() 266 | 267 | # THEN 268 | assert not stderr 269 | 270 | 271 | def test_parallel_threads_pytestxdist(testdir, monkeypatch, capfd): 272 | monkeypatch.chdir(testdir.tmpdir) 273 | testdir.makepyfile( 274 | SLEEPING_TEST_5S + TEST_FAILING_AFTER_TIMEOUT + NON_DEBUGGING_TEST 275 | ) 276 | 277 | testdir.runpytest("--pystack-threshold=1", "-s", "-n=3") 278 | 279 | # Outputs to stderr 280 | _, stderr = capfd.readouterr() 281 | 282 | assert "PYSTACK -- test_sleeping_test" in stderr 283 | assert "PYSTACK -- test_failing_after_timeout" in stderr 284 | assert "PYSTACK -- test_non_debugging_test" in stderr 285 | assert "time.sleep(5)" in stderr 286 | 287 | 288 | def test_two_slow_tests_in_a_suite_prints_both(testdir, monkeypatch, capfd): 289 | monkeypatch.chdir(testdir.tmpdir) 290 | 291 | test_case = f""" 292 | {SLEEPING_TEST_5S} 293 | 294 | {SLEEPING_TEST_5S.replace('test_sleeping_test', 'test_sleeping_test2')} 295 | """ 296 | 297 | testdir.makepyfile(test_case) 298 | 299 | testdir.runpytest("--pystack-threshold=1", "-s") 300 | 301 | # Outputs to stderr 302 | _, stderr = capfd.readouterr() 303 | print(stderr) 304 | assert "PYSTACK -- test_sleeping_test" in stderr 305 | assert "PYSTACK -- test_sleeping_test2" in stderr 306 | 307 | 308 | def test_pytester_compat(testdir, capfd, monkeypatch): 309 | """Make sure that our make_napari_viewer plugin works.""" 310 | 311 | # create a temporary pytest test file 312 | 313 | monkeypatch.chdir(testdir.tmpdir) 314 | testdir.makepyfile( 315 | """ 316 | pytest_plugins = 'pytester' 317 | 318 | test_file =''' 319 | import time 320 | 321 | 322 | def test_sleep(): 323 | time.sleep(1) 324 | assert 1 == 1 325 | ''' 326 | 327 | 328 | def test_pytester(pytester): 329 | pytester.makepyfile(test_file) 330 | result = pytester.runpytest() 331 | result.assert_outcomes(passed=1) 332 | """ 333 | ) 334 | result = testdir.runpytest("--pystack-threshold=3", "-s") 335 | 336 | # check that all 1 test passed 337 | result.assert_outcomes(passed=1) 338 | 339 | _, stderr = capfd.readouterr() 340 | assert not stderr 341 | --------------------------------------------------------------------------------