├── .containerignore ├── .gitignore ├── CHANGELOG.md ├── Containerfile ├── LICENSE ├── README.md ├── pystack.py ├── setup.py ├── test.sh ├── test_pystack.py └── tox.ini /.containerignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | build/ 3 | dist/ 4 | __pycache__/ 5 | .tox/ 6 | .python-version 7 | 8 | *.pyc 9 | *.egg-info 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # IPython Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | .pytest_cache 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.11.0 2 | 3 | - No more restrict the version range of click (GH-15) 4 | - Fix compatibility on Python 3.12 (GH-17) 5 | 6 | # 0.10.0 7 | 8 | - Fix command line arguments of newer LLDB/GDB (GH-14) 9 | - Fix entry points in `setup.py` (GH-11) 10 | - Test on Python 3.11 11 | 12 | # 0.9.0 13 | 14 | - Report error information if GDB/LLDB is not found. (GH-10) 15 | 16 | # 0.8.0 17 | 18 | - The Python 3.6 in both client and server are supported. (GH-9) 19 | 20 | # 0.7.1 21 | 22 | - Rename the PyPI project to "pystack-debugger" since the "pystack" was used. 23 | 24 | # 0.7.0 25 | 26 | - Fix the incorrect arguments when using lldb (GH-7) 27 | - Rename the project and its script to "pystack" (GH-8) 28 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/fedora/fedora:37 2 | 3 | RUN dnf install -y --setopt=install_weak_deps=False \ 4 | python3.8 \ 5 | python3.9 \ 6 | python3.10 \ 7 | python3.11 \ 8 | python3.12 \ 9 | tox \ 10 | python-coverage \ 11 | gdb \ 12 | lldb \ 13 | which \ 14 | less \ 15 | procps-ng \ 16 | strace && \ 17 | dnf clean all 18 | WORKDIR /pystack 19 | 20 | CMD ["tox"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Haochuan Guo 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of pystack nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/pystack-debugger.svg)](https://pypi.org/project/pystack-debugger/) 2 | 3 | # pystack-debugger 4 | 5 | The pystack-debugger is to python as jstack is to java. 6 | 7 | It's a debug tool to print python threads or greenlet stacks. 8 | 9 | Idea stolen from [pyrasite](https://github.com/lmacken/pyrasite). 10 | 11 | ## Install 12 | 13 | $ pip install pystack-debugger 14 | 15 | ## Usage 16 | 17 | You may need to run it with `sudo`. 18 | 19 | $ sudo pystack [--include-greenlet] 20 | 21 | ## Compatibility 22 | 23 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pystack-debugger.svg)](https://pypi.org/project/pystack-debugger/) 24 | [![PyPI - Implementation](https://img.shields.io/pypi/implementation/pystack-debugger.svg)](https://pypi.org/project/pystack-debugger/) 25 | 26 | The pystack is compatible with CPython 3.8+ in both client side (the debugger) 27 | and server side (the destination process). 28 | 29 | Using PyPy may work in client side (the debugger) but it is untested. Do not 30 | attempt to attach a PyPy process as destination. It may lead to unexpected and 31 | undefined behavior, because the pystack debugger uses gdb/lldb to invoke the 32 | CPython ABI. 33 | 34 | ## Development 35 | 36 | Run testing on a container environment: 37 | 38 | $ podman machine start 39 | $ ./test.sh 40 | $ ./test.sh coverage html 41 | -------------------------------------------------------------------------------- /pystack.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import absolute_import, print_function 4 | 5 | import os 6 | import sys 7 | import subprocess 8 | import tempfile 9 | import platform 10 | import functools 11 | import codecs 12 | import locale 13 | import shutil 14 | 15 | import click 16 | 17 | 18 | FILE_OPEN_COMMAND = r'f = open(\"%s\", \"w\")' 19 | FILE_CLOSE_COMMAND = r'f.close()' 20 | 21 | UTILITY_COMMANDS = [ 22 | r'itervalues = lambda d:getattr(d, \"itervalues\", d.values)()', 23 | ] 24 | 25 | GREENLET_STACK_COMMANDS = [ 26 | r'import gc,greenlet,traceback', 27 | r'objs=[ob for ob in gc.get_objects() if ' 28 | r'isinstance(ob,greenlet.greenlet) if ob]', 29 | r'f.write(\"\\nDumping Greenlets....\\n\\n\\n\")', 30 | r'f.write(\"\\n---------------\\n\\n\".join(' 31 | r'\"\".join(traceback.format_stack(o.gr_frame)) for o in objs))', 32 | ] 33 | 34 | THREAD_STACK_COMMANDS = [ 35 | r'import traceback,sys', 36 | r'f.write(\"Dumping Threads....\\n\\n\\n\")', 37 | r'f.write(\"\\n---------------\\n\\n\".join(' 38 | r'\"\".join(traceback.format_stack(o)) for o in ' 39 | r'itervalues(sys._current_frames())))', 40 | ] 41 | 42 | 43 | def make_gdb_args(pid, command): 44 | statements = [ 45 | r'call (void *) PyGILState_Ensure()', 46 | r'call (void) PyRun_SimpleString("exec(r\"\"\"%s\"\"\")")' % command, 47 | r'call (void) PyGILState_Release((void *) $1)', 48 | ] 49 | arguments = [find_debugger('gdb'), '-p', str(pid), '-nx', '-batch'] 50 | arguments.extend(['-iex', 'set debuginfod enabled on']) 51 | arguments.extend("-eval-command=%s" % s for s in statements) 52 | return arguments 53 | 54 | 55 | def make_lldb_args(pid, command): 56 | statements = [ 57 | r'expr void * $gil = (void *) PyGILState_Ensure()', 58 | r'expr (void) PyRun_SimpleString("exec(r\"\"\"%s\"\"\")")' % command, 59 | r'expr (void) PyGILState_Release($gil)', 60 | ] 61 | arguments = [find_debugger('lldb'), '-p', str(pid), '--batch'] 62 | for s in statements: 63 | arguments.extend(['--one-line', s]) 64 | return arguments 65 | 66 | 67 | def find_debugger(name): 68 | debugger = shutil.which(name) 69 | if not debugger: 70 | raise DebuggerNotFound( 71 | 'Could not find "%s" in your PATH environment variable' % name) 72 | return debugger 73 | 74 | 75 | class DebuggerNotFound(Exception): 76 | pass 77 | 78 | 79 | def print_stack(pid, include_greenlet=False, debugger=None, verbose=False): 80 | """Executes a file in a running Python process.""" 81 | # TextIOWrapper of Python 3 is so strange. 82 | sys_stdout = getattr(sys.stdout, 'buffer', sys.stdout) 83 | sys_stderr = getattr(sys.stderr, 'buffer', sys.stderr) 84 | 85 | make_args = make_gdb_args 86 | environ = dict(os.environ) 87 | if ( 88 | debugger == 'lldb' or 89 | (debugger is None and platform.system().lower() == 'darwin') 90 | ): 91 | make_args = make_lldb_args 92 | # fix the PATH environment variable for using built-in Python with lldb 93 | environ['PATH'] = '/usr/bin:%s' % environ.get('PATH', '') 94 | 95 | tmp_fd, tmp_path = tempfile.mkstemp() 96 | os.chmod(tmp_path, 0o777) 97 | commands = [] 98 | commands.append(FILE_OPEN_COMMAND) 99 | commands.extend(UTILITY_COMMANDS) 100 | commands.extend(THREAD_STACK_COMMANDS) 101 | if include_greenlet: 102 | commands.extend(GREENLET_STACK_COMMANDS) 103 | commands.append(FILE_CLOSE_COMMAND) 104 | command = r';'.join(commands) 105 | 106 | args = make_args(pid, command % tmp_path) 107 | process = subprocess.Popen( 108 | args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 109 | out, err = process.communicate() 110 | if verbose: 111 | sys_stderr.write(b'Standard Output:\n%s\n' % out) 112 | sys_stderr.write(b'Standard Error:\n%s\n' % err) 113 | sys_stderr.flush() 114 | 115 | for chunk in iter(functools.partial(os.read, tmp_fd, 1024), b''): 116 | sys_stdout.write(chunk) 117 | sys_stdout.write(b'\n') 118 | sys_stdout.flush() 119 | 120 | 121 | CONTEXT_SETTINGS = { 122 | 'help_option_names': ['-h', '--help'], 123 | } 124 | 125 | 126 | @click.command(context_settings=CONTEXT_SETTINGS) 127 | @click.argument('pid', required=True, type=int) 128 | @click.option('--include-greenlet', default=False, is_flag=True, 129 | help="Also print greenlet stacks") 130 | @click.option('-d', '--debugger', type=click.Choice(['gdb', 'lldb'])) 131 | @click.option('-v', '--verbose', default=False, is_flag=True, 132 | help="Verbosely print error and warnings") 133 | def cli_main(pid, include_greenlet, debugger, verbose): 134 | '''Print stack of python process. 135 | 136 | $ pystack 137 | ''' 138 | try: 139 | print_stack(pid, include_greenlet, debugger, verbose) 140 | except DebuggerNotFound as e: 141 | click.echo('DebuggerNotFound: %s' % e.args[0], err=True) 142 | click.get_current_context().exit(1) 143 | 144 | 145 | def tolerate_missing_locale(): 146 | if codecs.lookup(locale.getpreferredencoding()).name != 'ascii': 147 | return 148 | # Dear Click, we really don't need any non-ascii output. Please don't 149 | # crash yourself because you don't like the unicode design of Python 3. 150 | # (http://click.pocoo.org/5/python3/#python-3-surrogate-handling) 151 | os.environ.setdefault('LC_ALL', 'C.UTF-8') 152 | os.environ.setdefault('LANG', 'C.UTF-8') 153 | 154 | 155 | def main(): 156 | tolerate_missing_locale() 157 | cli_main() 158 | 159 | 160 | if __name__ == '__main__': 161 | main() 162 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | name='pystack-debugger', 7 | version='0.11.0', 8 | description='Tool to print python thread and greenlet stacks', 9 | long_description=open('README.md').read(), 10 | long_description_content_type='text/markdown', 11 | author='Haochuan Guo', 12 | author_email='guohaochuan@gmail.com', 13 | maintainer='Jiangge Zhang', 14 | maintainer_email='tonyseek@gmail.com', 15 | py_modules=['pystack'], 16 | zip_safe=False, 17 | license='MIT', 18 | url='https://github.com/wooparadog/pystack/', 19 | keywords=['pystack', 'pstack', 'jstack', 'gdb', 'lldb', 'greenlet'], 20 | entry_points={ 21 | 'console_scripts': [ 22 | 'pystack = pystack:main', 23 | ], 24 | }, 25 | install_requires=[ 26 | 'click', 27 | ], 28 | platforms=['linux', 'darwin'], 29 | classifiers=[ 30 | 'Development Status :: 3 - Alpha', 31 | 'Intended Audience :: Developers', 32 | 'Intended Audience :: System Administrators', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Operating System :: MacOS', 35 | 'Operating System :: POSIX', 36 | 'Operating System :: POSIX :: Linux', 37 | 'Programming Language :: Python :: 3', 38 | 'Programming Language :: Python :: 3.8', 39 | 'Programming Language :: Python :: 3.9', 40 | 'Programming Language :: Python :: 3.10', 41 | 'Programming Language :: Python :: 3.11', 42 | 'Programming Language :: Python :: 3.12', 43 | 'Programming Language :: Python :: Implementation :: CPython', 44 | 'Topic :: Software Development', 45 | 'Topic :: Software Development :: Debuggers', 46 | 'Topic :: Utilities', 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | OCI_NAME=ghcr.io/wooparadog/pystack:test 4 | podman build -t "$OCI_NAME" "$PWD" 5 | podman run -it --rm --cap-add SYS_PTRACE -v "$PWD:/pystack" "$OCI_NAME" "${@}" 6 | -------------------------------------------------------------------------------- /test_pystack.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import sys 4 | import subprocess 5 | import platform 6 | import time 7 | import shutil 8 | 9 | from pytest import fixture, mark, param, raises 10 | from click.testing import CliRunner 11 | 12 | from pystack import ( 13 | cli_main, tolerate_missing_locale, find_debugger, DebuggerNotFound) 14 | 15 | 16 | skipif_non_gdb = mark.skipif( 17 | shutil.which('gdb') is None, reason='gdb not found') 18 | skipif_non_lldb = mark.skipif( 19 | shutil.which('lldb') is None, reason='lldb not found') 20 | skipif_darwin = mark.skipif( 21 | platform.system().lower() == 'darwin', reason='gdb on darwin is unstable') 22 | 23 | 24 | STATEMENTS = { 25 | 'sleep': '__import__("time").sleep(360)', 26 | } 27 | 28 | 29 | @fixture 30 | def process(request): 31 | args = [sys.executable, '-c', request.param] 32 | process = subprocess.Popen(args) 33 | try: 34 | time.sleep(1) 35 | yield process 36 | finally: 37 | process.terminate() 38 | process.wait() 39 | 40 | 41 | @fixture 42 | def cli(): 43 | tolerate_missing_locale() 44 | return CliRunner() 45 | 46 | 47 | def test_find_debugger(): 48 | assert find_debugger('true') == '/usr/bin/true' 49 | with raises(DebuggerNotFound) as error: 50 | find_debugger('shhhhhhhhhhhhhhhhhhhhhhhhh') 51 | assert error.value.args[0] == ( 52 | 'Could not find "shhhhhhhhhhhhhhhhhhhhhhhhh" in your' 53 | ' PATH environment variable') 54 | 55 | 56 | @mark.parametrize(('process', 'debugger'), [ 57 | param(STATEMENTS['sleep'], 'gdb', marks=[skipif_non_gdb, skipif_darwin]), 58 | param(STATEMENTS['sleep'], 'lldb', marks=skipif_non_lldb), 59 | ], indirect=['process']) 60 | def test_smoke(cli, process, debugger): 61 | result = cli.invoke(cli_main, [str(process.pid), '--debugger', debugger]) 62 | assert not result.exception 63 | assert result.exit_code == 0 64 | assert ' File "", line 1, in \n' in result.output 65 | 66 | 67 | @mark.parametrize('process', [STATEMENTS['sleep']], indirect=['process']) 68 | def test_smoke_debugger_not_found(cli, mocker, process): 69 | mocker.patch('pystack.find_debugger', side_effect=DebuggerNotFound('oops')) 70 | result = cli.invoke(cli_main, [str(process.pid)]) 71 | assert result.exit_code == 1 72 | assert 'DebuggerNotFound: oops' in result.output 73 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py310,py311,py312 3 | 4 | [testenv] 5 | changedir = {toxinidir} 6 | deps = 7 | pytest 8 | pytest-cov 9 | pytest-pep8 10 | pytest-mock 11 | commands = 12 | py.test --cov=pystack {posargs} 13 | --------------------------------------------------------------------------------