├── tests ├── samples │ ├── __init__.py │ ├── not_code.txt │ ├── cython_example.pyx │ ├── to_exec.py │ ├── pygments_example.py │ ├── formatter_example.py │ ├── pieces.py │ └── example.py ├── golden_files │ ├── format_frame.txt │ ├── blank_invisible_no_linenos.txt │ ├── blank_visible_no_linenos.txt │ ├── linenos_no_current_line_indicator.txt │ ├── blank_single.txt │ ├── f_string_3.8.txt │ ├── f_string_old.txt │ ├── blank_visible.txt │ ├── print_stack.txt │ ├── single_option_linenos_no_current_line_indicator.txt │ ├── blank_visible_with_linenos_no_current_line_indicator.txt │ ├── cython_example.txt │ ├── f_string_new.txt │ ├── format_stack.txt │ ├── block_right_new.txt │ ├── block_right_old.txt │ ├── block_left_new.txt │ ├── block_left_old.txt │ ├── pygmented_error.txt │ ├── plain.txt │ ├── variables.txt │ └── pygmented.txt ├── __init__.py ├── utils.py ├── test_serializer.py ├── test_utils.py ├── test_formatter.py └── test_core.py ├── MANIFEST.in ├── setup.py ├── stack_data ├── py.typed ├── __init__.py ├── utils.py ├── serializing.py ├── formatting.py └── core.py ├── .gitignore ├── tox.ini ├── pyproject.toml ├── make_release.sh ├── LICENSE.txt ├── .github └── workflows │ └── pytest.yml ├── setup.cfg └── README.md /tests/samples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/samples/not_code.txt: -------------------------------------------------------------------------------- 1 | this isn't code -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | include stack_data/py.typed 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /stack_data/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. The ``stack_data`` package uses inline types. 2 | -------------------------------------------------------------------------------- /tests/samples/cython_example.pyx: -------------------------------------------------------------------------------- 1 | def foo(): 2 | bar() 3 | 4 | cdef bar(): 5 | raise ValueError("bar!") 6 | -------------------------------------------------------------------------------- /tests/samples/to_exec.py: -------------------------------------------------------------------------------- 1 | import stack_data 2 | import inspect 3 | 4 | frame_info = stack_data.FrameInfo(inspect.currentframe()) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | build 3 | stack_data/version.py 4 | .eggs 5 | .pytest_cache 6 | .tox 7 | pip-wheel-metadata 8 | venv 9 | *.egg-info 10 | *.pyc 11 | *.pyo 12 | __pycache__ 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{38,39,310,311,312,313} 3 | 4 | [testenv] 5 | commands = pytest {posargs} 6 | extras = tests 7 | passenv = 8 | STACK_DATA_SLOW_TESTS 9 | FIX_STACK_DATA_TESTS 10 | -------------------------------------------------------------------------------- /tests/golden_files/format_frame.txt: -------------------------------------------------------------------------------- 1 | File "formatter_example.py", line 51, in format_frame 2 | 49 | def format_frame(formatter): 3 | 50 | frame = inspect.currentframe() 4 | --> 51 | return formatter.format_frame(frame) 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=44", "wheel", "setuptools_scm[toml]>=3.4.3"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | write_to = "stack_data/version.py" 7 | write_to_template = "__version__ = '{version}'\n" 8 | -------------------------------------------------------------------------------- /tests/golden_files/blank_invisible_no_linenos.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 85, in blank_lines 3 | def blank_lines(): 4 | a = [1, 2, 3] 5 | length = len(a) 6 | return a[length] 7 | ^^^^^^^^^ 8 | IndexError: list index out of range 9 | -------------------------------------------------------------------------------- /tests/golden_files/blank_visible_no_linenos.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 85, in blank_lines 3 | def blank_lines(): 4 | a = [1, 2, 3] 5 | 6 | length = len(a) 7 | 8 | 9 | return a[length] 10 | ^^^^^^^^^ 11 | IndexError: list index out of range 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pyximport 4 | try: 5 | from typeguard import install_import_hook 6 | except ImportError: 7 | from typeguard.importhook import install_import_hook 8 | 9 | pyximport.install(language_level=3) 10 | 11 | if not os.environ.get("STACK_DATA_SLOW_TESTS"): 12 | install_import_hook(["stack_data"]) 13 | -------------------------------------------------------------------------------- /tests/golden_files/linenos_no_current_line_indicator.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 85, in blank_lines 3 | 79 | def blank_lines(): 4 | 80 | a = [1, 2, 3] 5 | 82 | length = len(a) 6 | 85 | return a[length] 7 | ^^^^^^^^^ 8 | IndexError: list index out of range 9 | -------------------------------------------------------------------------------- /tests/golden_files/blank_single.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 85, in blank_lines 3 | 79 | def blank_lines(): 4 | 80 | a = [1, 2, 3] 5 | 81 | 6 | 82 | length = len(a) 7 | : 8 | --> 85 | return a[length] 9 | ^^^^^^^^^ 10 | IndexError: list index out of range 11 | -------------------------------------------------------------------------------- /tests/golden_files/f_string_3.8.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 57, in f_string 3 | 54 | def f_string(): 4 | 55 | f"""{str 5 | 56 | ( 6 | --> 57 | 1 / 7 | 58 | 0 + 4 8 | 59 | + 5 9 | 60 | ) 10 | 61 | }""" 11 | ZeroDivisionError: division by zero 12 | -------------------------------------------------------------------------------- /tests/golden_files/f_string_old.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 61, in f_string 3 | 54 | def f_string(): 4 | 55 | f"""{str 5 | 56 | ( 6 | 57 | 1 / 7 | 58 | 0 + 4 8 | 59 | + 5 9 | 60 | ) 10 | --> 61 | }""" 11 | ZeroDivisionError: division by zero 12 | -------------------------------------------------------------------------------- /tests/golden_files/blank_visible.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 85, in blank_lines 3 | 79 | def blank_lines(): 4 | 80 | a = [1, 2, 3] 5 | 81 | 6 | 82 | length = len(a) 7 | 83 | 8 | 84 | 9 | --> 85 | return a[length] 10 | ^^^^^^^^^ 11 | IndexError: list index out of range 12 | -------------------------------------------------------------------------------- /tests/golden_files/print_stack.txt: -------------------------------------------------------------------------------- 1 | File "formatter_example.py", line 34, in print_stack1 2 | 33 | def print_stack1(formatter): 3 | --> 34 | print_stack2(formatter) 4 | ^^^^^^^^^^^^^^^^^^^^^^^ 5 | File "formatter_example.py", line 38, in print_stack2 6 | 37 | def print_stack2(formatter): 7 | --> 38 | formatter.print_stack() 8 | ^^^^^^^^^^^^^^^^^^^^^^^ 9 | -------------------------------------------------------------------------------- /tests/golden_files/single_option_linenos_no_current_line_indicator.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 85, in blank_lines 3 | 79 | def blank_lines(): 4 | 80 | a = [1, 2, 3] 5 | 81 | 6 | 82 | length = len(a) 7 | : 8 | 85 | return a[length] 9 | ^^^^^^^^^ 10 | IndexError: list index out of range 11 | -------------------------------------------------------------------------------- /tests/golden_files/blank_visible_with_linenos_no_current_line_indicator.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 85, in blank_lines 3 | 79 | def blank_lines(): 4 | 80 | a = [1, 2, 3] 5 | 81 | 6 | 82 | length = len(a) 7 | 83 | 8 | 84 | 9 | 85 | return a[length] 10 | ^^^^^^^^^ 11 | IndexError: list index out of range 12 | -------------------------------------------------------------------------------- /tests/golden_files/cython_example.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "cython_example.pyx", line 2, in tests.samples.cython_example.foo 3 | 1 | def foo(): 4 | --> 2 | bar() 5 | 3 | 6 | File "cython_example.pyx", line 5, in tests.samples.cython_example.bar 7 | 2 | bar() 8 | 3 | 9 | 4 | cdef bar(): 10 | --> 5 | raise ValueError("bar!") 11 | ValueError: bar! 12 | -------------------------------------------------------------------------------- /tests/golden_files/f_string_new.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 57, in f_string 3 | 54 | def f_string(): 4 | 55 | f"""{str 5 | 56 | ( 6 | --> 57 | 1 / 7 | ^^^ 8 | 58 | 0 + 4 9 | ^ 10 | 59 | + 5 11 | 60 | ) 12 | 61 | }""" 13 | ZeroDivisionError: division by zero 14 | -------------------------------------------------------------------------------- /tests/golden_files/format_stack.txt: -------------------------------------------------------------------------------- 1 | File "formatter_example.py", line 42, in format_stack1 2 | 41 | def format_stack1(formatter): 3 | --> 42 | return format_stack2(formatter) 4 | ^^^^^^^^^^^^^^^^^^^^^^^^ 5 | File "formatter_example.py", line 46, in format_stack2 6 | 45 | def format_stack2(formatter): 7 | --> 46 | return list(formatter.format_stack()) 8 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 9 | -------------------------------------------------------------------------------- /stack_data/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Source, FrameInfo, markers_from_ranges, Options, LINE_GAP, Line, Variable, RangeInLine, \ 2 | RepeatedFrames, MarkerInLine, style_with_executing_node, BlankLineRange, BlankLines 3 | from .formatting import Formatter 4 | from .serializing import Serializer 5 | 6 | try: 7 | from .version import __version__ 8 | except ImportError: 9 | # version.py is auto-generated with the git tag when building 10 | __version__ = "???" 11 | -------------------------------------------------------------------------------- /tests/golden_files/block_right_new.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 65, in block_right 3 | 64 | def block_right(): 4 | --> 65 | nb = len(letter 5 | ^^^^^^^^^^ 6 | 66 | for letter 7 | ^^^^^^^^^^ 8 | 67 | in 9 | ^^^^ 10 | 68 | "words") 11 | ^^^^^^^^ 12 | TypeError: object of type 'generator' has no len() 13 | -------------------------------------------------------------------------------- /tests/golden_files/block_right_old.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 68, in block_right 3 | 64 | def block_right(): 4 | 65 | nb = len(letter 5 | ^^^^^^^^^^ 6 | 66 | for letter 7 | ^^^^^^^^^^ 8 | 67 | in 9 | ^^^^ 10 | --> 68 | "words") 11 | ^^^^^^^^ 12 | TypeError: object of type 'generator' has no len() 13 | -------------------------------------------------------------------------------- /tests/golden_files/block_left_new.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 72, in block_left 3 | 71 | def block_left(): 4 | --> 72 | nb_characters = len(letter 5 | ^^^^^^^^^^ 6 | 73 | for letter 7 | ^^^^^^^^^^ 8 | 74 | 9 | 75 | in 10 | ^^^^ 11 | 76 | "words") 12 | ^^^^^^^^ 13 | TypeError: object of type 'generator' has no len() 14 | -------------------------------------------------------------------------------- /tests/golden_files/block_left_old.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 76, in block_left 3 | 71 | def block_left(): 4 | 72 | nb_characters = len(letter 5 | ^^^^^^^^^^ 6 | 73 | for letter 7 | ^^^^^^^^^^ 8 | 74 | 9 | 75 | in 10 | ^^^^ 11 | --> 76 | "words") 12 | ^^^^^^^^ 13 | TypeError: object of type 'generator' has no len() 14 | -------------------------------------------------------------------------------- /make_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | # Ensure that there are no uncommitted changes 5 | # which would mess up using the git tag as a version 6 | [ -z "$(git status --porcelain)" ] 7 | 8 | if [ -z "${1+x}" ] 9 | then 10 | set +x 11 | echo Provide a version argument 12 | echo "${0} .." 13 | exit 1 14 | else 15 | if [[ ${1} =~ ^([0-9]+)(\.[0-9]+)?(\.[0-9]+)?$ ]]; then 16 | : 17 | else 18 | echo "Not a valid release tag." 19 | exit 1 20 | fi 21 | fi 22 | 23 | tox -p 3 24 | 25 | export TAG="v${1}" 26 | git tag "${TAG}" 27 | git push origin master "${TAG}" 28 | rm -rf ./build ./dist 29 | python -m build --sdist --wheel . 30 | twine upload ./dist/*.whl dist/*.tar.gz 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alex Hall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pygments 4 | from littleutils import string_to_file, file_to_string, json_to_file, file_to_json 5 | 6 | 7 | def parse_version(version: str): 8 | return tuple(int(x) for x in version.split(".")) 9 | 10 | 11 | old_pygments = parse_version(pygments.__version__) < (2, 16, 1) 12 | 13 | 14 | def compare_to_file(text, name): 15 | if old_pygments and "pygment" in name: 16 | return 17 | filename = os.path.join( 18 | os.path.dirname(__file__), 19 | 'golden_files', 20 | name + '.txt', 21 | ) 22 | if os.environ.get('FIX_STACK_DATA_TESTS'): 23 | string_to_file(text, filename) 24 | else: 25 | expected_output = file_to_string(filename) 26 | assert text == expected_output 27 | 28 | 29 | def compare_to_file_json(data, name, *, pygmented): 30 | if old_pygments and pygmented: 31 | return 32 | filename = os.path.join( 33 | os.path.dirname(__file__), 34 | 'golden_files', 35 | name + '.json', 36 | ) 37 | if os.environ.get('FIX_STACK_DATA_TESTS'): 38 | json_to_file(data, filename, indent=4) 39 | else: 40 | expected_output = file_to_json(filename) 41 | assert data == expected_output 42 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-20.04 13 | strategy: 14 | matrix: 15 | python-version: [3.8, 3.9, '3.10', 3.11, '3.12-dev', '3.13-dev'] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: run tests 23 | env: 24 | STACK_DATA_SLOW_TESTS: 1 25 | run: | 26 | pip install --upgrade pip 27 | pip install --upgrade coveralls setuptools setuptools_scm pep517 28 | pip install .[tests] 29 | coverage run --source stack_data -m pytest 30 | coverage report -m 31 | - name: Coveralls Python 32 | uses: AndreMiras/coveralls-python-action@v20201129 33 | with: 34 | parallel: true 35 | flag-name: test-${{ matrix.python-version }} 36 | coveralls_finish: 37 | needs: build 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Coveralls Finished 41 | uses: AndreMiras/coveralls-python-action@v20201129 42 | with: 43 | parallel-finished: true 44 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = stack_data 3 | author = Alex Hall 4 | author_email = alex.mojaki@gmail.com 5 | license = MIT 6 | description = Extract data from python stack frames and tracebacks for informative displays 7 | url = http://github.com/alexmojaki/stack_data 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | classifiers = 11 | Intended Audience :: Developers 12 | Programming Language :: Python :: 3.8 13 | Programming Language :: Python :: 3.9 14 | Programming Language :: Python :: 3.10 15 | Programming Language :: Python :: 3.11 16 | Programming Language :: Python :: 3.12 17 | Programming Language :: Python :: 3.13 18 | License :: OSI Approved :: MIT License 19 | Operating System :: OS Independent 20 | Topic :: Software Development :: Debuggers 21 | 22 | [options] 23 | packages = stack_data 24 | install_requires = 25 | executing>=1.2.0 26 | asttokens>=2.1.0 27 | pure_eval>=0.1.0 28 | 29 | setup_requires = setuptools>=44; setuptools_scm[toml]>=3.4.3 30 | include_package_data = True 31 | tests_require = pytest; typeguard; pygments; littleutils 32 | 33 | [options.extras_require] 34 | tests = pytest; typeguard; pygments; littleutils; cython; setuptools 35 | 36 | [coverage:run] 37 | relative_files = True 38 | 39 | [options.package_data] 40 | stack_data = py.typed 41 | -------------------------------------------------------------------------------- /tests/test_serializer.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import re 3 | 4 | from stack_data import FrameInfo 5 | from stack_data.serializing import Serializer 6 | from tests.utils import compare_to_file_json 7 | 8 | 9 | class MyFormatter(Serializer): 10 | def should_include_frame(self, frame_info: FrameInfo) -> bool: 11 | return frame_info.filename.endswith(("formatter_example.py", "", "cython_example.pyx")) 12 | 13 | def format_variable_value(self, value) -> str: 14 | result = super().format_variable_value(value) 15 | result = re.sub(r'0x\w+', '0xABC', result) 16 | return result 17 | 18 | def format_frame(self, frame) -> dict: 19 | result = super().format_frame(frame) 20 | result["filename"] = os.path.basename(result["filename"]) 21 | return result 22 | 23 | 24 | def test_example(): 25 | from .samples.formatter_example import bar, format_frame, format_stack1 26 | 27 | result = dict( 28 | format_frame=(format_frame(MyFormatter())), 29 | format_stack=format_stack1(MyFormatter(show_variables=True)), 30 | ) 31 | 32 | try: 33 | bar() 34 | except Exception: 35 | result.update( 36 | plain=MyFormatter(show_variables=True).format_exception(), 37 | pygmented=MyFormatter(show_variables=True, pygmented=True).format_exception(), 38 | pygmented_html=MyFormatter(show_variables=True, pygmented=True, html=True).format_exception(), 39 | ) 40 | 41 | 42 | compare_to_file_json(result, "serialize", pygmented=True) 43 | -------------------------------------------------------------------------------- /tests/samples/pygments_example.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from pygments.formatters.html import HtmlFormatter 4 | from pygments.formatters.terminal import TerminalFormatter 5 | from pygments.formatters.terminal256 import Terminal256Formatter, TerminalTrueColorFormatter 6 | from stack_data import FrameInfo, Options, style_with_executing_node 7 | 8 | 9 | def identity(x): 10 | return x 11 | 12 | 13 | def bar(): 14 | x = 1 15 | str(x) 16 | 17 | @deco 18 | def foo(): 19 | pass 20 | pass 21 | pass 22 | return foo.result 23 | 24 | 25 | def deco(f): 26 | f.result = print_stack() 27 | return f 28 | 29 | 30 | def print_stack(): 31 | result = "" 32 | for formatter_cls in [ 33 | Terminal256Formatter, 34 | TerminalFormatter, 35 | TerminalTrueColorFormatter, 36 | HtmlFormatter, 37 | ]: 38 | for style in ["native", style_with_executing_node("native", "bg:#444400")]: 39 | result += "{formatter_cls.__name__} {style}:\n\n".format(**locals()) 40 | formatter = formatter_cls(style=style) 41 | options = Options(pygments_formatter=formatter) 42 | frame = inspect.currentframe().f_back 43 | for frame_info in list(FrameInfo.stack_data(frame, options))[-2:]: 44 | for line in frame_info.lines: 45 | result += '{:4} | {}\n'.format( 46 | line.lineno, 47 | line.render(pygmented=True) 48 | ) 49 | result += "-----\n" 50 | result += "\n====================\n\n" 51 | return result 52 | 53 | 54 | if __name__ == '__main__': 55 | print(bar()) 56 | print(repr(bar()).replace("\\n", "\n")[1:-1]) 57 | -------------------------------------------------------------------------------- /tests/samples/formatter_example.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from stack_data import Formatter 4 | 5 | 6 | def foo(n=5): 7 | if n > 0: 8 | return foo(n - 1) 9 | x = 1 10 | lst = ( 11 | [ 12 | x, 13 | ] 14 | + [] 15 | + [] 16 | + [] 17 | + [] 18 | + [] 19 | ) 20 | try: 21 | return int(str(lst)) 22 | except: 23 | try: 24 | return 1 / 0 25 | except Exception as e: 26 | raise TypeError from e 27 | 28 | 29 | def bar(): 30 | exec("foo()") 31 | 32 | 33 | def print_stack1(formatter): 34 | print_stack2(formatter) 35 | 36 | 37 | def print_stack2(formatter): 38 | formatter.print_stack() 39 | 40 | 41 | def format_stack1(formatter): 42 | return format_stack2(formatter) 43 | 44 | 45 | def format_stack2(formatter): 46 | return list(formatter.format_stack()) 47 | 48 | 49 | def format_frame(formatter): 50 | frame = inspect.currentframe() 51 | return formatter.format_frame(frame) 52 | 53 | 54 | def f_string(): 55 | f"""{str 56 | ( 57 | 1 / 58 | 0 + 4 59 | + 5 60 | ) 61 | }""" 62 | 63 | 64 | def block_right(): 65 | nb = len(letter 66 | for letter 67 | in 68 | "words") 69 | 70 | 71 | def block_left(): 72 | nb_characters = len(letter 73 | for letter 74 | 75 | in 76 | "words") 77 | 78 | 79 | def blank_lines(): 80 | a = [1, 2, 3] 81 | 82 | length = len(a) 83 | 84 | 85 | return a[length] 86 | 87 | 88 | 89 | if __name__ == '__main__': 90 | try: 91 | bar() 92 | except Exception: 93 | Formatter(show_variables=True).print_exception() 94 | -------------------------------------------------------------------------------- /tests/golden_files/pygmented_error.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 21, in foo 3 | 9 | x = 1 4 | 10 | lst = ( 5 | 11 | [ 6 | 12 | x, 7 | (...) 8 | 18 | + [] 9 | 19 | ) 10 | 20 | try: 11 | --> 21 | return int(str(lst)) 12 | 22 | except: 13 | ValueError: invalid literal for int() with base 10: '[1]' 14 | 15 | During handling of the above exception, another exception occurred: 16 | 17 | Traceback (most recent call last): 18 | File "formatter_example.py", line 24, in foo 19 | 21 | return int(str(lst)) 20 | 22 | except: 21 | 23 | try: 22 | --> 24 | return 1 / 0 23 | 25 | except Exception as e: 24 | ZeroDivisionError: division by zero 25 | 26 | The above exception was the direct cause of the following exception: 27 | 28 | Traceback (most recent call last): 29 | File "formatter_example.py", line 30, in bar 30 | 29 | def bar(): 31 | --> 30 | exec("foo()") 32 | File "", line 1, in 33 | File "formatter_example.py", line 8, in foo 34 | 6 | def foo(n=5): 35 | 7 | if n > 0: 36 | --> 8 | return foo(n - 1) 37 | 9 | x = 1 38 | File "formatter_example.py", line 8, in foo 39 | 6 | def foo(n=5): 40 | 7 | if n > 0: 41 | --> 8 | return foo(n - 1) 42 | 9 | x = 1 43 | [... skipping similar frames: foo at line 8 (2 times)] 44 | File "formatter_example.py", line 8, in foo 45 | 6 | def foo(n=5): 46 | 7 | if n > 0: 47 | --> 8 | return foo(n - 1) 48 | 9 | x = 1 49 | File "formatter_example.py", line 26, in foo 50 | 23 | try: 51 | 24 | return 1 / 0 52 | 25 | except Exception as e: 53 | --> 26 | raise TypeError from e 54 | TypeError 55 | -------------------------------------------------------------------------------- /tests/golden_files/plain.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 21, in foo 3 | 9 | x = 1 4 | 10 | lst = ( 5 | 11 | [ 6 | 12 | x, 7 | (...) 8 | 18 | + [] 9 | 19 | ) 10 | 20 | try: 11 | --> 21 | return int(str(lst)) 12 | ^^^^^^^^^^^^^ 13 | 22 | except: 14 | ValueError: invalid literal for int() with base 10: '[1]' 15 | 16 | During handling of the above exception, another exception occurred: 17 | 18 | Traceback (most recent call last): 19 | File "formatter_example.py", line 24, in foo 20 | 21 | return int(str(lst)) 21 | 22 | except: 22 | 23 | try: 23 | --> 24 | return 1 / 0 24 | ^^^^^ 25 | 25 | except Exception as e: 26 | ZeroDivisionError: division by zero 27 | 28 | The above exception was the direct cause of the following exception: 29 | 30 | Traceback (most recent call last): 31 | File "formatter_example.py", line 30, in bar 32 | 29 | def bar(): 33 | --> 30 | exec("foo()") 34 | ^^^^^^^^^^^^^ 35 | File "", line 1, in 36 | File "formatter_example.py", line 8, in foo 37 | 6 | def foo(n=5): 38 | 7 | if n > 0: 39 | --> 8 | return foo(n - 1) 40 | ^^^^^^^^^^ 41 | 9 | x = 1 42 | File "formatter_example.py", line 8, in foo 43 | 6 | def foo(n=5): 44 | 7 | if n > 0: 45 | --> 8 | return foo(n - 1) 46 | ^^^^^^^^^^ 47 | 9 | x = 1 48 | [... skipping similar frames: foo at line 8 (2 times)] 49 | File "formatter_example.py", line 8, in foo 50 | 6 | def foo(n=5): 51 | 7 | if n > 0: 52 | --> 8 | return foo(n - 1) 53 | ^^^^^^^^^^ 54 | 9 | x = 1 55 | File "formatter_example.py", line 26, in foo 56 | 23 | try: 57 | 24 | return 1 / 0 58 | 25 | except Exception as e: 59 | --> 26 | raise TypeError from e 60 | TypeError 61 | -------------------------------------------------------------------------------- /tests/samples/pieces.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | 4 | def foo(x=1, y=2): 5 | """ 6 | a docstring 7 | """ 8 | z = 0 9 | for i in range(5): 10 | z += i * x * math.sin(y) 11 | # comment1 12 | # comment2 13 | z += math.copysign( 14 | -1, 15 | 2, 16 | ) 17 | 18 | for i in range( 19 | 0, 20 | 6 21 | ): 22 | try: 23 | str(i) 24 | except: 25 | pass 26 | 27 | try: 28 | int(i) 29 | except (ValueError, 30 | TypeError): 31 | pass 32 | finally: 33 | str(""" 34 | foo 35 | """) 36 | str(f""" 37 | {str(str)} 38 | """) 39 | str(f""" 40 | foo 41 | { 42 | str( 43 | str 44 | ) 45 | } 46 | bar 47 | {str(str)} 48 | baz 49 | { 50 | str( 51 | str 52 | ) 53 | } 54 | spam 55 | """) 56 | 57 | 58 | def foo2( 59 | x=1, 60 | y=2, 61 | ): 62 | while 9: 63 | while ( 64 | 9 + 9 65 | ): 66 | if 1: 67 | pass 68 | elif 2: 69 | pass 70 | elif ( 71 | 3 + 3 72 | ): 73 | pass 74 | else: 75 | pass 76 | 77 | 78 | class Foo(object): 79 | @property 80 | def foo(self): 81 | return 3 82 | 83 | 84 | # noinspection PyTrailingSemicolon 85 | def semicolons(): 86 | if 1: 87 | print(1, 88 | 2); print(3, 89 | 4); print(5, 90 | 6) 91 | if 2: 92 | print(1, 93 | 2); print(3, 4); print(5, 94 | 6) 95 | print(1, 2); print(3, 96 | 4); print(5, 6) 97 | print(1, 2);print(3, 4);print(5, 6) 98 | -------------------------------------------------------------------------------- /tests/samples/example.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from stack_data import FrameInfo, Options, Line, LINE_GAP, markers_from_ranges 4 | 5 | 6 | def foo(): 7 | x = 1 8 | lst = [1] 9 | 10 | lst.insert(0, x) 11 | lst.append( 12 | [ 13 | 1, 14 | 2, 15 | 3, 16 | 4, 17 | 5, 18 | 6 19 | ][0]) 20 | result = print_stack( 21 | ) 22 | return result 23 | 24 | 25 | def bar(): 26 | names = {} 27 | exec("result = foo()", globals(), names) 28 | return names["result"] 29 | 30 | 31 | def print_stack(): 32 | result = "" 33 | options = Options(include_signature=True) 34 | frame = inspect.currentframe().f_back 35 | for frame_info in list(FrameInfo.stack_data(frame, options))[-3:]: 36 | result += render_frame_info(frame_info) + "\n" 37 | return result 38 | 39 | 40 | def render_frame_info(frame_info): 41 | result = "{} at line {}".format( 42 | frame_info.executing.code_qualname(), 43 | frame_info.lineno 44 | ) 45 | result += '\n' + len(result) * '-' + '\n' 46 | 47 | for line in frame_info.lines: 48 | def convert_variable_range(_): 49 | return "", "" 50 | 51 | def convert_executing_range(_): 52 | return "", "" 53 | 54 | if isinstance(line, Line): 55 | markers = ( 56 | markers_from_ranges(line.variable_ranges, convert_variable_range) + 57 | markers_from_ranges(line.executing_node_ranges, convert_executing_range) 58 | ) 59 | result += '{:4} {} {}\n'.format( 60 | line.lineno, 61 | '>' if line.is_current else '|', 62 | line.render(markers) 63 | ) 64 | else: 65 | assert line is LINE_GAP 66 | result += '(...)\n' 67 | 68 | for var in sorted(frame_info.variables, key=lambda v: v.name): 69 | result += " ".join([var.name, '=', repr(var.value), '\n']) 70 | return result 71 | 72 | 73 | if __name__ == '__main__': 74 | print(bar()) 75 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | from collections import Counter 3 | 4 | from stack_data import FrameInfo 5 | from stack_data.utils import highlight_unique, collapse_repeated, cached_property 6 | 7 | 8 | def assert_collapsed(lst, expected, summary): 9 | assert ''.join(collapse_repeated(lst, collapser=lambda group, _: '.' * len(group))) == expected 10 | assert list(collapse_repeated(lst, collapser=lambda group, _: Counter(group))) == summary 11 | 12 | 13 | def test_collapse_repeated(): 14 | assert_collapsed( 15 | '0123456789BBCBCBBCBACBACBBBBCABABBABCCCCAACBABBCBBBAAACBBBCABACACCAACABBCBCCBBABBAAAAACBCCCAAAABBCBB', 16 | '0123456789BBC.C....A..A.......................................................................A..C.B', 17 | ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', 'B', 'C', Counter({'B': 1}), 'C', 18 | Counter({'B': 3, 'C': 1}), 'A', Counter({'C': 1, 'B': 1}), 'A', Counter({'B': 26, 'A': 24, 'C': 21}), 'A', 19 | Counter({'B': 2}), 'C', Counter({'B': 1}), 'B'] 20 | ) 21 | 22 | assert_collapsed( 23 | 'BAAABABC3BCBBCBBCBAACBBBABBCCACCACB7BBBCA8ABB9B0AACABBCACCCCAAAAABBBBBCA2CBABCCCBB4ACCAACBBA1BBCB6A5', 24 | 'BAA.BABC3BCB.C....AA............ACB7BBBCA8ABB9B0AAC.BBC..............BCA2CBABC.C.B4ACCA.CBBA1BBCB6A5', 25 | ['B', 'A', 'A', Counter({'A': 1}), 'B', 'A', 'B', 'C', '3', 'B', 'C', 'B', Counter({'B': 1}), 'C', 26 | Counter({'B': 3, 'C': 1}), 'A', 'A', Counter({'C': 5, 'B': 5, 'A': 2}), 27 | 'A', 'C', 'B', '7', 'B', 'B', 'B', 'C', 'A', '8', 'A', 'B', 'B', '9', 'B', '0', 'A', 'A', 'C', 28 | Counter({'A': 1}), 'B', 'B', 'C', Counter({'A': 6, 'C': 4, 'B': 4}), 29 | 'B', 'C', 'A', '2', 'C', 'B', 'A', 'B', 'C', Counter({'C': 1}), 'C', 30 | Counter({'B': 1}), 'B', '4', 'A', 'C', 'C', 'A', Counter({'A': 1}), 31 | 'C', 'B', 'B', 'A', '1', 'B', 'B', 'C', 'B', '6', 'A', '5'], 32 | ) 33 | 34 | 35 | def test_highlight_unique_properties(): 36 | for _ in range(20): 37 | lst = list('0123456789' * 3) + [random.choice('ABCD') for _ in range(1000)] 38 | random.shuffle(lst) 39 | result = list(highlight_unique(lst)) 40 | assert len(lst) == len(result) 41 | vals, highlighted = zip(*result) 42 | assert set(vals) == set('0123456789ABCD') 43 | assert set(highlighted) == {True, False} 44 | 45 | 46 | def test_cached_property_from_class(): 47 | assert FrameInfo.filename is FrameInfo.__dict__["filename"] 48 | assert isinstance(FrameInfo.filename, cached_property) 49 | -------------------------------------------------------------------------------- /tests/golden_files/variables.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 21, in foo 3 | 9 | x = 1 4 | 10 | lst = ( 5 | 11 | [ 6 | 12 | x, 7 | (...) 8 | 18 | + [] 9 | 19 | ) 10 | 20 | try: 11 | --> 21 | return int(str(lst)) 12 | ^^^^^^^^^^^^^ 13 | 22 | except: 14 | [ 15 | x, 16 | ] = [1] 17 | [ 18 | x, 19 | ] 20 | + [] = [1] 21 | [ 22 | x, 23 | ] 24 | + [] 25 | + [] = [1] 26 | [ 27 | x, 28 | ] 29 | + [] 30 | + [] 31 | + [] = [1] 32 | [ 33 | x, 34 | ] 35 | + [] 36 | + [] 37 | + [] 38 | + [] = [1] 39 | [ 40 | x, 41 | ] 42 | + [] 43 | + [] 44 | + [] 45 | + [] 46 | + [] = [1] 47 | lst = [1] 48 | n = 0 49 | n - 1 = -1 50 | n > 0 = False 51 | str(lst) = '[1]' 52 | x = 1 53 | ValueError: invalid literal for int() with base 10: '[1]' 54 | 55 | During handling of the above exception, another exception occurred: 56 | 57 | Traceback (most recent call last): 58 | File "formatter_example.py", line 24, in foo 59 | 21 | return int(str(lst)) 60 | 22 | except: 61 | 23 | try: 62 | --> 24 | return 1 / 0 63 | ^^^^^ 64 | 25 | except Exception as e: 65 | [ 66 | x, 67 | ] = [1] 68 | [ 69 | x, 70 | ] 71 | + [] = [1] 72 | [ 73 | x, 74 | ] 75 | + [] 76 | + [] = [1] 77 | [ 78 | x, 79 | ] 80 | + [] 81 | + [] 82 | + [] = [1] 83 | [ 84 | x, 85 | ] 86 | + [] 87 | + [] 88 | + [] 89 | + [] = [1] 90 | [ 91 | x, 92 | ] 93 | + [] 94 | + [] 95 | + [] 96 | + [] 97 | + [] = [1] 98 | lst = [1] 99 | n = 0 100 | n - 1 = -1 101 | n > 0 = False 102 | str(lst) = '[1]' 103 | x = 1 104 | ZeroDivisionError: division by zero 105 | 106 | The above exception was the direct cause of the following exception: 107 | 108 | Traceback (most recent call last): 109 | File "formatter_example.py", line 30, in bar 110 | 29 | def bar(): 111 | --> 30 | exec("foo()") 112 | ^^^^^^^^^^^^^ 113 | File "", line 1, in 114 | File "formatter_example.py", line 8, in foo 115 | 6 | def foo(n=5): 116 | 7 | if n > 0: 117 | --> 8 | return foo(n - 1) 118 | ^^^^^^^^^^ 119 | 9 | x = 1 120 | n = 5 121 | n - 1 = 4 122 | n > 0 = True 123 | File "formatter_example.py", line 8, in foo 124 | 6 | def foo(n=5): 125 | 7 | if n > 0: 126 | --> 8 | return foo(n - 1) 127 | ^^^^^^^^^^ 128 | 9 | x = 1 129 | n = 4 130 | n - 1 = 3 131 | n > 0 = True 132 | [... skipping similar frames: foo at line 8 (2 times)] 133 | File "formatter_example.py", line 8, in foo 134 | 6 | def foo(n=5): 135 | 7 | if n > 0: 136 | --> 8 | return foo(n - 1) 137 | ^^^^^^^^^^ 138 | 9 | x = 1 139 | n = 1 140 | n - 1 = 0 141 | n > 0 = True 142 | File "formatter_example.py", line 26, in foo 143 | 23 | try: 144 | 24 | return 1 / 0 145 | 25 | except Exception as e: 146 | --> 26 | raise TypeError from e 147 | [ 148 | x, 149 | ] = [1] 150 | [ 151 | x, 152 | ] 153 | + [] = [1] 154 | [ 155 | x, 156 | ] 157 | + [] 158 | + [] = [1] 159 | [ 160 | x, 161 | ] 162 | + [] 163 | + [] 164 | + [] = [1] 165 | [ 166 | x, 167 | ] 168 | + [] 169 | + [] 170 | + [] 171 | + [] = [1] 172 | [ 173 | x, 174 | ] 175 | + [] 176 | + [] 177 | + [] 178 | + [] 179 | + [] = [1] 180 | lst = [1] 181 | n = 0 182 | n - 1 = -1 183 | n > 0 = False 184 | str(lst) = '[1]' 185 | x = 1 186 | TypeError 187 | -------------------------------------------------------------------------------- /tests/golden_files/pygmented.txt: -------------------------------------------------------------------------------- 1 | Traceback (most recent call last): 2 | File "formatter_example.py", line 21, in foo 3 | 9 | x = 1 4 | 10 | lst = ( 5 | 11 |  [ 6 | 12 |  x, 7 | (...) 8 | 18 |  + [] 9 | 19 | ) 10 | 20 | try: 11 | --> 21 |  return int(str(lst)) 12 | 22 | except: 13 | ValueError: invalid literal for int() with base 10: '[1]' 14 | 15 | During handling of the above exception, another exception occurred: 16 | 17 | Traceback (most recent call last): 18 | File "formatter_example.py", line 24, in foo 19 | 21 |  return int(str(lst)) 20 | 22 | except: 21 | 23 |  try: 22 | --> 24 |  return 1 / 0 23 | 25 |  except Exception as e: 24 | ZeroDivisionError: division by zero 25 | 26 | The above exception was the direct cause of the following exception: 27 | 28 | Traceback (most recent call last): 29 | File "formatter_example.py", line 30, in bar 30 | 29 | def bar(): 31 | --> 30 |  exec("foo()") 32 | File "", line 1, in 33 | File "formatter_example.py", line 8, in foo 34 | 6 | def foo(n=5): 35 | 7 |  if n > 0: 36 | --> 8 |  return foo(n - 1) 37 | 9 |  x = 1 38 | File "formatter_example.py", line 8, in foo 39 | 6 | def foo(n=5): 40 | 7 |  if n > 0: 41 | --> 8 |  return foo(n - 1) 42 | 9 |  x = 1 43 | [... skipping similar frames: foo at line 8 (2 times)] 44 | File "formatter_example.py", line 8, in foo 45 | 6 | def foo(n=5): 46 | 7 |  if n > 0: 47 | --> 8 |  return foo(n - 1) 48 | 9 |  x = 1 49 | File "formatter_example.py", line 26, in foo 50 | 23 | try: 51 | 24 |  return 1 / 0 52 | 25 | except Exception as e: 53 | --> 26 |  raise TypeError from e 54 | TypeError 55 | -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | from contextlib import contextmanager 5 | 6 | import pygments 7 | import pytest 8 | from asttokens.util import fstring_positions_work 9 | 10 | from stack_data import Formatter, FrameInfo, Options, BlankLines 11 | from tests.utils import compare_to_file 12 | 13 | 14 | class BaseFormatter(Formatter): 15 | def format_frame_header(self, frame_info: FrameInfo) -> str: 16 | # noinspection PyPropertyAccess 17 | frame_info.filename = os.path.basename(frame_info.filename) 18 | return super().format_frame_header(frame_info) 19 | 20 | def format_variable_value(self, value) -> str: 21 | result = super().format_variable_value(value) 22 | result = re.sub(r'0x\w+', '0xABC', result) 23 | return result 24 | 25 | 26 | class MyFormatter(BaseFormatter): 27 | def format_frame(self, frame): 28 | if not frame.filename.endswith(("formatter_example.py", "", "cython_example.pyx")): 29 | return 30 | yield from super().format_frame(frame) 31 | 32 | 33 | def test_example(capsys): 34 | from .samples.formatter_example import bar, print_stack1, format_stack1, format_frame, f_string, blank_lines 35 | 36 | @contextmanager 37 | def check_example(name): 38 | yield 39 | stderr = capsys.readouterr().err 40 | compare_to_file(stderr, name) 41 | 42 | with check_example("variables"): 43 | try: 44 | bar() 45 | except Exception: 46 | MyFormatter(show_variables=True).print_exception() 47 | 48 | with check_example("pygmented"): 49 | try: 50 | bar() 51 | except Exception: 52 | MyFormatter(pygmented=True).print_exception() 53 | 54 | with check_example("plain"): 55 | MyFormatter().set_hook() 56 | try: 57 | bar() 58 | except Exception: 59 | sys.excepthook(*sys.exc_info()) 60 | 61 | with check_example("pygmented_error"): 62 | h = pygments.highlight 63 | pygments.highlight = lambda *args, **kwargs: 1/0 64 | try: 65 | bar() 66 | except Exception: 67 | MyFormatter(pygmented=True).print_exception() 68 | finally: 69 | pygments.highlight = h 70 | 71 | with check_example("print_stack"): 72 | print_stack1(MyFormatter()) 73 | 74 | with check_example("format_stack"): 75 | formatter = MyFormatter() 76 | formatted = format_stack1(formatter) 77 | formatter.print_lines(formatted) 78 | 79 | with check_example("format_frame"): 80 | formatter = BaseFormatter() 81 | formatted = format_frame(formatter) 82 | formatter.print_lines(formatted) 83 | 84 | if sys.version_info[:2] < (3, 8): 85 | f_string_suffix = 'old' 86 | elif not fstring_positions_work(): 87 | f_string_suffix = '3.8' 88 | else: 89 | f_string_suffix = 'new' 90 | 91 | with check_example(f"f_string_{f_string_suffix}"): 92 | try: 93 | f_string() 94 | except Exception: 95 | MyFormatter().print_exception() 96 | 97 | from .samples.formatter_example import block_right, block_left 98 | 99 | with check_example(f"block_right_{'old' if sys.version_info[:2] < (3, 8) else 'new'}"): 100 | try: 101 | block_right() 102 | except Exception: 103 | MyFormatter().print_exception() 104 | 105 | with check_example(f"block_left_{'old' if sys.version_info[:2] < (3, 8) else 'new'}"): 106 | try: 107 | block_left() 108 | except Exception: 109 | MyFormatter().print_exception() 110 | 111 | from .samples import cython_example 112 | 113 | with check_example("cython_example"): 114 | try: 115 | cython_example.foo() 116 | except Exception: 117 | MyFormatter().print_exception() 118 | 119 | with check_example("blank_visible"): 120 | try: 121 | blank_lines() 122 | except Exception: 123 | MyFormatter(options=Options(blank_lines=BlankLines.VISIBLE)).print_exception() 124 | 125 | with check_example("blank_single"): 126 | try: 127 | blank_lines() 128 | except Exception: 129 | MyFormatter(options=Options(blank_lines=BlankLines.SINGLE)).print_exception() 130 | 131 | with check_example("blank_invisible_no_linenos"): 132 | try: 133 | blank_lines() 134 | except Exception: 135 | MyFormatter(show_linenos=False, current_line_indicator="").print_exception() 136 | 137 | with check_example("blank_visible_no_linenos"): 138 | try: 139 | blank_lines() 140 | except Exception: 141 | MyFormatter(show_linenos=False, 142 | current_line_indicator="", 143 | options=Options(blank_lines=BlankLines.VISIBLE)).print_exception() 144 | 145 | with check_example("linenos_no_current_line_indicator"): 146 | try: 147 | blank_lines() 148 | except Exception: 149 | MyFormatter(current_line_indicator="").print_exception() 150 | 151 | with check_example("blank_visible_with_linenos_no_current_line_indicator"): 152 | try: 153 | blank_lines() 154 | except Exception: 155 | MyFormatter(current_line_indicator="", 156 | options=Options(blank_lines=BlankLines.VISIBLE)).print_exception() 157 | 158 | with check_example("single_option_linenos_no_current_line_indicator"): 159 | try: 160 | blank_lines() 161 | except Exception: 162 | MyFormatter(current_line_indicator="", 163 | options=Options(blank_lines=BlankLines.SINGLE)).print_exception() 164 | 165 | def test_invalid_single_option(): 166 | with pytest.raises(ValueError): 167 | MyFormatter(show_linenos=False, options=Options(blank_lines=BlankLines.SINGLE)) 168 | 169 | -------------------------------------------------------------------------------- /stack_data/utils.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import itertools 3 | import types 4 | from collections import OrderedDict, Counter, defaultdict 5 | from types import FrameType, TracebackType 6 | from typing import ( 7 | Iterator, List, Tuple, Iterable, Callable, Union, 8 | TypeVar, Mapping, 9 | ) 10 | 11 | from asttokens import ASTText 12 | 13 | T = TypeVar('T') 14 | R = TypeVar('R') 15 | 16 | 17 | def truncate(seq, max_length: int, middle): 18 | if len(seq) > max_length: 19 | right = (max_length - len(middle)) // 2 20 | left = max_length - len(middle) - right 21 | seq = seq[:left] + middle + seq[-right:] 22 | return seq 23 | 24 | 25 | def unique_in_order(it: Iterable[T]) -> List[T]: 26 | return list(OrderedDict.fromkeys(it)) 27 | 28 | 29 | def line_range(atok: ASTText, node: ast.AST) -> Tuple[int, int]: 30 | """ 31 | Returns a pair of numbers representing a half open range 32 | (i.e. suitable as arguments to the `range()` builtin) 33 | of line numbers of the given AST nodes. 34 | """ 35 | if isinstance(node, getattr(ast, "match_case", ())): 36 | start, _end = line_range(atok, node.pattern) 37 | _start, end = line_range(atok, node.body[-1]) 38 | return start, end 39 | else: 40 | (start, _), (end, _) = atok.get_text_positions(node, padded=False) 41 | return start, end + 1 42 | 43 | 44 | def highlight_unique(lst: List[T]) -> Iterator[Tuple[T, bool]]: 45 | counts = Counter(lst) 46 | 47 | for is_common, group in itertools.groupby(lst, key=lambda x: counts[x] > 3): 48 | if is_common: 49 | group = list(group) 50 | highlighted = [False] * len(group) 51 | 52 | def highlight_index(f): 53 | try: 54 | i = f() 55 | except ValueError: 56 | return None 57 | highlighted[i] = True 58 | return i 59 | 60 | for item in set(group): 61 | first = highlight_index(lambda: group.index(item)) 62 | if first is not None: 63 | highlight_index(lambda: group.index(item, first + 1)) 64 | highlight_index(lambda: -1 - group[::-1].index(item)) 65 | else: 66 | highlighted = itertools.repeat(True) 67 | 68 | yield from zip(group, highlighted) 69 | 70 | 71 | def identity(x: T) -> T: 72 | return x 73 | 74 | 75 | def collapse_repeated(lst, *, collapser, mapper=identity, key=identity): 76 | keyed = list(map(key, lst)) 77 | for is_highlighted, group in itertools.groupby( 78 | zip(lst, highlight_unique(keyed)), 79 | key=lambda t: t[1][1], 80 | ): 81 | original_group, highlighted_group = zip(*group) 82 | if is_highlighted: 83 | yield from map(mapper, original_group) 84 | else: 85 | keyed_group, _ = zip(*highlighted_group) 86 | yield collapser(list(original_group), list(keyed_group)) 87 | 88 | 89 | def is_frame(frame_or_tb: Union[FrameType, TracebackType]) -> bool: 90 | assert_(isinstance(frame_or_tb, (types.FrameType, types.TracebackType))) 91 | return isinstance(frame_or_tb, (types.FrameType,)) 92 | 93 | 94 | def iter_stack(frame_or_tb: Union[FrameType, TracebackType]) -> Iterator[Union[FrameType, TracebackType]]: 95 | current: Union[FrameType, TracebackType, None] = frame_or_tb 96 | while current: 97 | yield current 98 | if is_frame(current): 99 | current = current.f_back 100 | else: 101 | current = current.tb_next 102 | 103 | 104 | def frame_and_lineno(frame_or_tb: Union[FrameType, TracebackType]) -> Tuple[FrameType, int]: 105 | if is_frame(frame_or_tb): 106 | return frame_or_tb, frame_or_tb.f_lineno 107 | else: 108 | return frame_or_tb.tb_frame, frame_or_tb.tb_lineno 109 | 110 | 111 | def group_by_key_func(iterable: Iterable[T], key_func: Callable[[T], R]) -> Mapping[R, List[T]]: 112 | # noinspection PyUnresolvedReferences 113 | """ 114 | Create a dictionary from an iterable such that the keys are the result of evaluating a key function on elements 115 | of the iterable and the values are lists of elements all of which correspond to the key. 116 | 117 | >>> def si(d): return sorted(d.items()) 118 | >>> si(group_by_key_func("a bb ccc d ee fff".split(), len)) 119 | [(1, ['a', 'd']), (2, ['bb', 'ee']), (3, ['ccc', 'fff'])] 120 | >>> si(group_by_key_func([-1, 0, 1, 3, 6, 8, 9, 2], lambda x: x % 2)) 121 | [(0, [0, 6, 8, 2]), (1, [-1, 1, 3, 9])] 122 | """ 123 | result = defaultdict(list) 124 | for item in iterable: 125 | result[key_func(item)].append(item) 126 | return result 127 | 128 | 129 | class cached_property(object): 130 | """ 131 | A property that is only computed once per instance and then replaces itself 132 | with an ordinary attribute. Deleting the attribute resets the property. 133 | 134 | Based on https://github.com/pydanny/cached-property/blob/master/cached_property.py 135 | """ 136 | 137 | def __init__(self, func): 138 | self.__doc__ = func.__doc__ 139 | self.func = func 140 | 141 | def cached_property_wrapper(self, obj, _cls): 142 | if obj is None: 143 | return self 144 | 145 | value = obj.__dict__[self.func.__name__] = self.func(obj) 146 | return value 147 | 148 | __get__ = cached_property_wrapper 149 | 150 | 151 | def _pygmented_with_ranges(formatter, code, ranges): 152 | import pygments 153 | from pygments.lexers import get_lexer_by_name 154 | 155 | class MyLexer(type(get_lexer_by_name("python3"))): 156 | def get_tokens(self, text): 157 | length = 0 158 | for ttype, value in super().get_tokens(text): 159 | if any(start <= length < end for start, end in ranges): 160 | ttype = ttype.ExecutingNode 161 | length += len(value) 162 | yield ttype, value 163 | 164 | lexer = MyLexer(stripnl=False) 165 | try: 166 | highlighted = pygments.highlight(code, lexer, formatter) 167 | except Exception: 168 | # When pygments fails, prefer code without highlighting over crashing 169 | highlighted = code 170 | return highlighted.splitlines() 171 | 172 | 173 | def assert_(condition, error=""): 174 | if not condition: 175 | if isinstance(error, str): 176 | error = AssertionError(error) 177 | raise error 178 | 179 | 180 | # Copied from the standard traceback module pre-3.11 181 | def some_str(value): 182 | try: 183 | return str(value) 184 | except: 185 | return '' % type(value).__name__ 186 | -------------------------------------------------------------------------------- /stack_data/serializing.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | import sys 4 | import traceback 5 | from collections import Counter 6 | from html import escape as escape_html 7 | from types import FrameType, TracebackType 8 | from typing import Union, Iterable, List 9 | 10 | from stack_data import ( 11 | style_with_executing_node, 12 | Options, 13 | Line, 14 | FrameInfo, 15 | Variable, 16 | RepeatedFrames, 17 | ) 18 | from stack_data.utils import some_str 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | 23 | class Serializer: 24 | def __init__( 25 | self, 26 | *, 27 | options=None, 28 | pygmented=False, 29 | show_executing_node=True, 30 | pygments_formatter_cls=None, 31 | pygments_formatter_kwargs=None, 32 | pygments_style="monokai", 33 | executing_node_modifier="bg:#005080", 34 | use_code_qualname=True, 35 | strip_leading_indent=True, 36 | html=False, 37 | chain=True, 38 | collapse_repeated_frames=True, 39 | show_variables=False, 40 | ): 41 | if options is None: 42 | options = Options() 43 | 44 | if pygmented and not options.pygments_formatter: 45 | if show_executing_node: 46 | pygments_style = style_with_executing_node( 47 | pygments_style, executing_node_modifier 48 | ) 49 | 50 | if pygments_formatter_cls is None: 51 | if html: 52 | from pygments.formatters.html import ( 53 | HtmlFormatter as pygments_formatter_cls, 54 | ) 55 | else: 56 | from pygments.formatters.terminal256 import ( 57 | Terminal256Formatter as pygments_formatter_cls, 58 | ) 59 | 60 | options.pygments_formatter = pygments_formatter_cls( 61 | style=pygments_style, 62 | **pygments_formatter_kwargs or {}, 63 | ) 64 | 65 | self.pygmented = pygmented 66 | self.use_code_qualname = use_code_qualname 67 | self.strip_leading_indent = strip_leading_indent 68 | self.html = html 69 | self.chain = chain 70 | self.options = options 71 | self.collapse_repeated_frames = collapse_repeated_frames 72 | self.show_variables = show_variables 73 | 74 | def format_exception(self, e=None) -> List[dict]: 75 | if e is None: 76 | e = sys.exc_info()[1] 77 | 78 | result = [] 79 | 80 | if self.chain: 81 | if e.__cause__ is not None: 82 | result = self.format_exception(e.__cause__) 83 | result[-1]["tail"] = traceback._cause_message.strip() 84 | elif e.__context__ is not None and not e.__suppress_context__: 85 | result = self.format_exception(e.__context__) 86 | result[-1]["tail"] = traceback._context_message.strip() 87 | 88 | result.append(self.format_traceback_part(e)) 89 | return result 90 | 91 | def format_traceback_part(self, e: BaseException) -> dict: 92 | return dict( 93 | frames=self.format_stack(e.__traceback__ or sys.exc_info()[2]), 94 | exception=dict( 95 | type=type(e).__name__, 96 | message=some_str(e), 97 | ), 98 | tail="", 99 | ) 100 | 101 | def format_stack(self, frame_or_tb=None) -> List[dict]: 102 | if frame_or_tb is None: 103 | frame_or_tb = inspect.currentframe().f_back 104 | 105 | return list( 106 | self.format_stack_data( 107 | FrameInfo.stack_data( 108 | frame_or_tb, 109 | self.options, 110 | collapse_repeated_frames=self.collapse_repeated_frames, 111 | ) 112 | ) 113 | ) 114 | 115 | def format_stack_data( 116 | self, stack: Iterable[Union[FrameInfo, RepeatedFrames]] 117 | ) -> Iterable[dict]: 118 | for item in stack: 119 | if isinstance(item, FrameInfo): 120 | if not self.should_include_frame(item): 121 | continue 122 | yield dict(type="frame", **self.format_frame(item)) 123 | else: 124 | yield dict(type="repeated_frames", **self.format_repeated_frames(item)) 125 | 126 | def format_repeated_frames(self, repeated_frames: RepeatedFrames) -> dict: 127 | counts = sorted( 128 | Counter(repeated_frames.frame_keys).items(), 129 | key=lambda item: (-item[1], item[0][0].co_name), 130 | ) 131 | return dict( 132 | frames=[ 133 | dict( 134 | name=code.co_name, 135 | lineno=lineno, 136 | count=count, 137 | ) 138 | for (code, lineno), count in counts 139 | ] 140 | ) 141 | 142 | def format_frame(self, frame: Union[FrameInfo, FrameType, TracebackType]) -> dict: 143 | if not isinstance(frame, FrameInfo): 144 | frame = FrameInfo(frame, self.options) 145 | 146 | result = dict( 147 | name=( 148 | frame.executing.code_qualname() 149 | if self.use_code_qualname 150 | else frame.code.co_name 151 | ), 152 | filename=frame.filename, 153 | lineno=frame.lineno, 154 | lines=list(self.format_lines(frame.lines)), 155 | ) 156 | if self.show_variables: 157 | result["variables"] = list(self.format_variables(frame)) 158 | return result 159 | 160 | def format_lines(self, lines): 161 | for line in lines: 162 | if isinstance(line, Line): 163 | yield dict(type="line", **self.format_line(line)) 164 | else: 165 | yield dict(type="line_gap") 166 | 167 | def format_line(self, line: Line) -> dict: 168 | return dict( 169 | is_current=line.is_current, 170 | lineno=line.lineno, 171 | text=line.render( 172 | pygmented=self.pygmented, 173 | escape_html=self.html, 174 | strip_leading_indent=self.strip_leading_indent, 175 | ), 176 | ) 177 | 178 | def format_variables(self, frame_info: FrameInfo) -> Iterable[dict]: 179 | try: 180 | for var in sorted(frame_info.variables, key=lambda v: v.name): 181 | yield self.format_variable(var) 182 | except Exception: # pragma: no cover 183 | log.exception("Error in getting frame variables") 184 | 185 | def format_variable(self, var: Variable) -> dict: 186 | return dict( 187 | name=self.format_variable_part(var.name), 188 | value=self.format_variable_part(self.format_variable_value(var.value)), 189 | ) 190 | 191 | def format_variable_part(self, text): 192 | if self.html: 193 | return escape_html(text) 194 | else: 195 | return text 196 | 197 | def format_variable_value(self, value) -> str: 198 | return repr(value) 199 | 200 | def should_include_frame(self, frame_info: FrameInfo) -> bool: 201 | return True # pragma: no cover 202 | -------------------------------------------------------------------------------- /stack_data/formatting.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import sys 3 | import traceback 4 | from types import FrameType, TracebackType 5 | from typing import Union, Iterable 6 | 7 | from stack_data import (style_with_executing_node, Options, Line, FrameInfo, LINE_GAP, 8 | Variable, RepeatedFrames, BlankLineRange, BlankLines) 9 | from stack_data.utils import assert_ 10 | 11 | 12 | class Formatter: 13 | def __init__( 14 | self, *, 15 | options=None, 16 | pygmented=False, 17 | show_executing_node=True, 18 | pygments_formatter_cls=None, 19 | pygments_formatter_kwargs=None, 20 | pygments_style="monokai", 21 | executing_node_modifier="bg:#005080", 22 | executing_node_underline="^", 23 | current_line_indicator="-->", 24 | line_gap_string="(...)", 25 | line_number_gap_string=":", 26 | line_number_format_string="{:4} | ", 27 | show_variables=False, 28 | use_code_qualname=True, 29 | show_linenos=True, 30 | strip_leading_indent=True, 31 | html=False, 32 | chain=True, 33 | collapse_repeated_frames=True 34 | ): 35 | if options is None: 36 | options = Options() 37 | 38 | if pygmented and not options.pygments_formatter: 39 | if show_executing_node: 40 | pygments_style = style_with_executing_node( 41 | pygments_style, executing_node_modifier 42 | ) 43 | 44 | if pygments_formatter_cls is None: 45 | from pygments.formatters.terminal256 import Terminal256Formatter \ 46 | as pygments_formatter_cls 47 | 48 | options.pygments_formatter = pygments_formatter_cls( 49 | style=pygments_style, 50 | **pygments_formatter_kwargs or {}, 51 | ) 52 | 53 | self.pygmented = pygmented 54 | self.show_executing_node = show_executing_node 55 | assert_( 56 | len(executing_node_underline) == 1, 57 | ValueError("executing_node_underline must be a single character"), 58 | ) 59 | self.executing_node_underline = executing_node_underline 60 | self.current_line_indicator = current_line_indicator or "" 61 | self.line_gap_string = line_gap_string 62 | self.line_number_gap_string = line_number_gap_string 63 | self.line_number_format_string = line_number_format_string 64 | self.show_variables = show_variables 65 | self.show_linenos = show_linenos 66 | self.use_code_qualname = use_code_qualname 67 | self.strip_leading_indent = strip_leading_indent 68 | self.html = html 69 | self.chain = chain 70 | self.options = options 71 | self.collapse_repeated_frames = collapse_repeated_frames 72 | if not self.show_linenos and self.options.blank_lines == BlankLines.SINGLE: 73 | raise ValueError( 74 | "BlankLines.SINGLE option can only be used when show_linenos=True" 75 | ) 76 | 77 | def set_hook(self): 78 | def excepthook(_etype, evalue, _tb): 79 | self.print_exception(evalue) 80 | 81 | sys.excepthook = excepthook 82 | 83 | def print_exception(self, e=None, *, file=None): 84 | self.print_lines(self.format_exception(e), file=file) 85 | 86 | def print_stack(self, frame_or_tb=None, *, file=None): 87 | if frame_or_tb is None: 88 | frame_or_tb = inspect.currentframe().f_back 89 | 90 | self.print_lines(self.format_stack(frame_or_tb), file=file) 91 | 92 | def print_lines(self, lines, *, file=None): 93 | if file is None: 94 | file = sys.stderr 95 | for line in lines: 96 | print(line, file=file, end="") 97 | 98 | def format_exception(self, e=None) -> Iterable[str]: 99 | if e is None: 100 | e = sys.exc_info()[1] 101 | 102 | if self.chain: 103 | if e.__cause__ is not None: 104 | yield from self.format_exception(e.__cause__) 105 | yield traceback._cause_message 106 | elif (e.__context__ is not None 107 | and not e.__suppress_context__): 108 | yield from self.format_exception(e.__context__) 109 | yield traceback._context_message 110 | 111 | yield 'Traceback (most recent call last):\n' 112 | yield from self.format_stack(e.__traceback__) 113 | yield from traceback.format_exception_only(type(e), e) 114 | 115 | def format_stack(self, frame_or_tb=None) -> Iterable[str]: 116 | if frame_or_tb is None: 117 | frame_or_tb = inspect.currentframe().f_back 118 | 119 | yield from self.format_stack_data( 120 | FrameInfo.stack_data( 121 | frame_or_tb, 122 | self.options, 123 | collapse_repeated_frames=self.collapse_repeated_frames, 124 | ) 125 | ) 126 | 127 | def format_stack_data( 128 | self, stack: Iterable[Union[FrameInfo, RepeatedFrames]] 129 | ) -> Iterable[str]: 130 | for item in stack: 131 | if isinstance(item, FrameInfo): 132 | yield from self.format_frame(item) 133 | else: 134 | yield self.format_repeated_frames(item) 135 | 136 | def format_repeated_frames(self, repeated_frames: RepeatedFrames) -> str: 137 | return ' [... skipping similar frames: {}]\n'.format( 138 | repeated_frames.description 139 | ) 140 | 141 | def format_frame(self, frame: Union[FrameInfo, FrameType, TracebackType]) -> Iterable[str]: 142 | if not isinstance(frame, FrameInfo): 143 | frame = FrameInfo(frame, self.options) 144 | 145 | yield self.format_frame_header(frame) 146 | 147 | for line in frame.lines: 148 | if isinstance(line, Line): 149 | yield self.format_line(line) 150 | elif isinstance(line, BlankLineRange): 151 | yield self.format_blank_lines_linenumbers(line) 152 | else: 153 | assert_(line is LINE_GAP) 154 | yield self.line_gap_string + "\n" 155 | 156 | if self.show_variables: 157 | try: 158 | yield from self.format_variables(frame) 159 | except Exception: 160 | pass 161 | 162 | def format_frame_header(self, frame_info: FrameInfo) -> str: 163 | return ' File "{frame_info.filename}", line {frame_info.lineno}, in {name}\n'.format( 164 | frame_info=frame_info, 165 | name=( 166 | frame_info.executing.code_qualname() 167 | if self.use_code_qualname else 168 | frame_info.code.co_name 169 | ), 170 | ) 171 | 172 | def format_line(self, line: Line) -> str: 173 | result = "" 174 | if self.current_line_indicator: 175 | if line.is_current: 176 | result = self.current_line_indicator 177 | else: 178 | result = " " * len(self.current_line_indicator) 179 | result += " " 180 | else: 181 | result = " " 182 | 183 | if self.show_linenos: 184 | result += self.line_number_format_string.format(line.lineno) 185 | 186 | prefix = result 187 | 188 | result += line.render( 189 | pygmented=self.pygmented, 190 | escape_html=self.html, 191 | strip_leading_indent=self.strip_leading_indent, 192 | ) + "\n" 193 | 194 | if self.show_executing_node and not self.pygmented: 195 | for line_range in line.executing_node_ranges: 196 | start = line_range.start - line.leading_indent 197 | end = line_range.end - line.leading_indent 198 | # if end <= start, we have an empty line inside a highlighted 199 | # block of code. In this case, we need to avoid inserting 200 | # an extra blank line with no markers present. 201 | if end > start: 202 | result += ( 203 | " " * (start + len(prefix)) 204 | + self.executing_node_underline * (end - start) 205 | + "\n" 206 | ) 207 | return result 208 | 209 | 210 | def format_blank_lines_linenumbers(self, blank_line): 211 | if self.current_line_indicator: 212 | result = " " * len(self.current_line_indicator) + " " 213 | else: 214 | result = " " 215 | if blank_line.begin_lineno == blank_line.end_lineno: 216 | return result + self.line_number_format_string.format(blank_line.begin_lineno) + "\n" 217 | return result + " {}\n".format(self.line_number_gap_string) 218 | 219 | 220 | def format_variables(self, frame_info: FrameInfo) -> Iterable[str]: 221 | for var in sorted(frame_info.variables, key=lambda v: v.name): 222 | try: 223 | yield self.format_variable(var) + "\n" 224 | except Exception: 225 | pass 226 | 227 | def format_variable(self, var: Variable) -> str: 228 | return "{} = {}".format( 229 | var.name, 230 | self.format_variable_value(var.value), 231 | ) 232 | 233 | def format_variable_value(self, value) -> str: 234 | return repr(value) 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stack_data 2 | 3 | [![Tests](https://github.com/alexmojaki/stack_data/actions/workflows/pytest.yml/badge.svg)](https://github.com/alexmojaki/stack_data/actions/workflows/pytest.yml) [![Coverage Status](https://coveralls.io/repos/github/alexmojaki/stack_data/badge.svg?branch=master)](https://coveralls.io/github/alexmojaki/stack_data?branch=master) [![Supports Python versions 3.5+](https://img.shields.io/pypi/pyversions/stack_data.svg)](https://pypi.python.org/pypi/stack_data) 4 | 5 | This is a library that extracts data from stack frames and tracebacks, particularly to display more useful tracebacks than the default. It powers the tracebacks in IPython and [futurecoder](https://futurecoder.io/): 6 | 7 | ![futurecoder example](https://futurecoder.io/static/img/features/traceback.png) 8 | 9 | You can install it from PyPI: 10 | 11 | pip install stack_data 12 | 13 | ## Basic usage 14 | 15 | Here's some code we'd like to inspect: 16 | 17 | ```python 18 | def foo(): 19 | result = [] 20 | for i in range(5): 21 | row = [] 22 | result.append(row) 23 | print_stack() 24 | for j in range(5): 25 | row.append(i * j) 26 | return result 27 | ``` 28 | 29 | Note that `foo` calls a function `print_stack()`. In reality we can imagine that an exception was raised at this line, or a debugger stopped there, but this is easy to play with directly. Here's a basic implementation: 30 | 31 | ```python 32 | import inspect 33 | import stack_data 34 | 35 | 36 | def print_stack(): 37 | frame = inspect.currentframe().f_back 38 | frame_info = stack_data.FrameInfo(frame) 39 | print(f"{frame_info.code.co_name} at line {frame_info.lineno}") 40 | print("-----------") 41 | for line in frame_info.lines: 42 | print(f"{'-->' if line.is_current else ' '} {line.lineno:4} | {line.render()}") 43 | ``` 44 | 45 | (Beware that this has a major bug - it doesn't account for line gaps, which we'll learn about later) 46 | 47 | The output of one call to `print_stack()` looks like: 48 | 49 | ``` 50 | foo at line 9 51 | ----------- 52 | 6 | for i in range(5): 53 | 7 | row = [] 54 | 8 | result.append(row) 55 | --> 9 | print_stack() 56 | 10 | for j in range(5): 57 | ``` 58 | 59 | The code for `print_stack()` is fairly self-explanatory. If you want to learn more details about a particular class or method I suggest looking through some docstrings. `FrameInfo` is a class that accepts either a frame or a traceback object and provides a bunch of nice attributes and properties (which are cached so you don't need to worry about performance). In particular `frame_info.lines` is a list of `Line` objects. `line.render()` returns the source code of that line suitable for display. Without any arguments it simply strips any common leading indentation. Later on we'll see a more powerful use for it. 60 | 61 | You can see that `frame_info.lines` includes some lines of surrounding context. By default it includes 3 pieces of context before the main line and 1 piece after. We can configure the amount of context by passing options: 62 | 63 | ```python 64 | options = stack_data.Options(before=1, after=0) 65 | frame_info = stack_data.FrameInfo(frame, options) 66 | ``` 67 | 68 | Then the output looks like: 69 | 70 | ``` 71 | foo at line 9 72 | ----------- 73 | 8 | result.append(row) 74 | --> 9 | print_stack() 75 | ``` 76 | 77 | Note that these parameters are not the number of *lines* before and after to include, but the number of *pieces*. A piece is a range of one or more lines in a file that should logically be grouped together. A piece contains either a single simple statement or a part of a compound statement (loops, if, try/except, etc) that doesn't contain any other statements. Most pieces are a single line, but a multi-line statement or `if` condition is a single piece. In the example above, all pieces are one line, because nothing is spread across multiple lines. If we change our code to include some multiline bits: 78 | 79 | 80 | ```python 81 | def foo(): 82 | result = [] 83 | for i in range(5): 84 | row = [] 85 | result.append( 86 | row 87 | ) 88 | print_stack() 89 | for j in range( 90 | 5 91 | ): 92 | row.append(i * j) 93 | return result 94 | ``` 95 | 96 | and then run the original code with the default options, then the output is: 97 | 98 | ``` 99 | foo at line 11 100 | ----------- 101 | 6 | for i in range(5): 102 | 7 | row = [] 103 | 8 | result.append( 104 | 9 | row 105 | 10 | ) 106 | --> 11 | print_stack() 107 | 12 | for j in range( 108 | 13 | 5 109 | 14 | ): 110 | ``` 111 | 112 | Now lines 8-10 and lines 12-14 are each a single piece. Note that the output is essentially the same as the original in terms of the amount of code. The division of files into pieces means that the edge of the context is intuitive and doesn't crop out parts of statements or expressions. For example, if context was measured in lines instead of pieces, the last line of the above would be `for j in range(` which is much less useful. 113 | 114 | However, if a piece is very long, including all of it could be cumbersome. For this, `Options` has a parameter `max_lines_per_piece`, which is 6 by default. Suppose we have a piece in our code that's longer than that: 115 | 116 | ```python 117 | row = [ 118 | 1, 119 | 2, 120 | 3, 121 | 4, 122 | 5, 123 | ] 124 | ``` 125 | 126 | `frame_info.lines` will truncate this piece so that instead of 7 `Line` objects it will produce 5 `Line` objects and one `LINE_GAP` in the middle, making 6 objects in total for the piece. Our code doesn't currently handle gaps, so it will raise an exception. We can modify it like so: 127 | 128 | ```python 129 | for line in frame_info.lines: 130 | if line is stack_data.LINE_GAP: 131 | print(" (...)") 132 | else: 133 | print(f"{'-->' if line.is_current else ' '} {line.lineno:4} | {line.render()}") 134 | ``` 135 | 136 | Now the output looks like: 137 | 138 | ``` 139 | foo at line 15 140 | ----------- 141 | 6 | for i in range(5): 142 | 7 | row = [ 143 | 8 | 1, 144 | 9 | 2, 145 | (...) 146 | 12 | 5, 147 | 13 | ] 148 | 14 | result.append(row) 149 | --> 15 | print_stack() 150 | 16 | for j in range(5): 151 | ``` 152 | 153 | Alternatively, you can flip the condition around and check `if isinstance(line, stack_data.Line):`. Either way, you should always check for line gaps, or your code may appear to work at first but fail when it encounters a long piece. 154 | 155 | Note that the executing piece, i.e. the piece containing the current line being executed (line 15 in this case) is never truncated, no matter how long it is. 156 | 157 | The lines of context never stray outside `frame_info.scope`, which is the innermost function or class definition containing the current line. For example, this is the output for a short function which has neither 3 lines before nor 1 line after the current line: 158 | 159 | ``` 160 | bar at line 6 161 | ----------- 162 | 4 | def bar(): 163 | 5 | foo() 164 | --> 6 | print_stack() 165 | ``` 166 | 167 | Sometimes it's nice to ensure that the function signature is always showing. This can be done with `Options(include_signature=True)`. The result looks like this: 168 | 169 | ``` 170 | foo at line 14 171 | ----------- 172 | 9 | def foo(): 173 | (...) 174 | 11 | for i in range(5): 175 | 12 | row = [] 176 | 13 | result.append(row) 177 | --> 14 | print_stack() 178 | 15 | for j in range(5): 179 | ``` 180 | 181 | To avoid wasting space, pieces never start or end with a blank line, and blank lines between pieces are excluded. So if our code looks like this: 182 | 183 | 184 | ```python 185 | for i in range(5): 186 | row = [] 187 | 188 | result.append(row) 189 | print_stack() 190 | 191 | for j in range(5): 192 | ``` 193 | 194 | The output doesn't change much, except you can see jumps in the line numbers: 195 | 196 | ``` 197 | 11 | for i in range(5): 198 | 12 | row = [] 199 | 14 | result.append(row) 200 | --> 15 | print_stack() 201 | 17 | for j in range(5): 202 | ``` 203 | 204 | ## Variables 205 | 206 | You can also inspect variables and other expressions in a frame, e.g: 207 | 208 | ```python 209 | for var in frame_info.variables: 210 | print(f"{var.name} = {repr(var.value)}") 211 | ``` 212 | 213 | which may output: 214 | 215 | ```python 216 | result = [[0, 0, 0, 0, 0], [0, 1, 2, 3, 4], [0, 2, 4, 6, 8], [0, 3, 6, 9, 12], []] 217 | i = 4 218 | row = [] 219 | j = 4 220 | ``` 221 | 222 | `frame_info.variables` returns a list of `Variable` objects, which have attributes `name`, `value`, and `nodes`, which is a list of all AST representing that expression. 223 | 224 | A `Variable` may refer to an expression other than a simple variable name. It can be any expression evaluated by the library [`pure_eval`](https://github.com/alexmojaki/pure_eval) which it deems 'interesting' (see those docs for more info). This includes expressions like `foo.bar` or `foo[bar]`. In these cases `name` is the source code of that expression. `pure_eval` ensures that it only evaluates expressions that won't have any side effects, e.g. where `foo.bar` is a normal attribute rather than a descriptor such as a property. 225 | 226 | `frame_info.variables` is a list of all the interesting expressions found in `frame_info.scope`, e.g. the current function, which may include expressions not visible in `frame_info.lines`. You can restrict the list by using `frame_info.variables_in_lines` or even `frame_info.variables_in_executing_piece`. For more control you can use `frame_info.variables_by_lineno`. See the docstrings for more information. 227 | 228 | ## Rendering lines with ranges and markers 229 | 230 | Sometimes you may want to insert special characters into the text for display purposes, e.g. HTML or ANSI color codes. `stack_data` provides a few tools to make this easier. 231 | 232 | Let's say we have a `Line` object where `line.text` (the original raw source code of that line) is `"foo = bar"`, so `line.text[6:9]` is `"bar"`, and we want to emphasise that part by inserting HTML at positions 6 and 9 in the text. Here's how we can do that directly: 233 | 234 | ```python 235 | markers = [ 236 | stack_data.MarkerInLine(position=6, is_start=True, string=""), 237 | stack_data.MarkerInLine(position=9, is_start=False, string=""), 238 | ] 239 | line.render(markers) # returns "foo = bar" 240 | ``` 241 | 242 | Here `is_start=True` indicates that the marker is the first of a pair. This helps `line.render()` sort and insert the markers correctly so you don't end up with malformed HTML like `foo.bar` where tags overlap. 243 | 244 | Since we're inserting HTML, we should actually use `line.render(markers, escape_html=True)` which will escape special HTML characters in the Python source (but not the markers) so for example `foo = bar < spam` would be rendered as `foo = bar < spam`. 245 | 246 | Usually though you wouldn't create markers directly yourself. Instead you would start with one or more ranges and then convert them, like so: 247 | 248 | ```python 249 | ranges = [ 250 | stack_data.RangeInLine(start=0, end=3, data="foo"), 251 | stack_data.RangeInLine(start=6, end=9, data="bar"), 252 | ] 253 | 254 | def convert_ranges(r): 255 | if r.data == "bar": 256 | return "", "" 257 | 258 | # This results in `markers` being the same as in the above example. 259 | markers = stack_data.markers_from_ranges(ranges, convert_ranges) 260 | ``` 261 | 262 | `RangeInLine` has a `data` attribute which can be any object. `markers_from_ranges` accepts a converter function to which it passes all the `RangeInLine` objects. If the converter function returns a pair of strings, it creates two markers from them. Otherwise it should return `None` to indicate that the range should be ignored, as with the first range containing `"foo"` in this example. 263 | 264 | The reason this is useful is because there are built in tools to create these ranges for you. For example, if we change our `print_stack()` function to contain this: 265 | 266 | ```python 267 | def convert_variable_ranges(r): 268 | variable, _node = r.data 269 | return f'', '' 270 | 271 | markers = stack_data.markers_from_ranges(line.variable_ranges, convert_variable_ranges) 272 | print(f"{'-->' if line.is_current else ' '} {line.lineno:4} | {line.render(markers, escape_html=True)}") 273 | ``` 274 | 275 | Then the output becomes: 276 | 277 | ``` 278 | foo at line 15 279 | ----------- 280 | 9 | def foo(): 281 | (...) 282 | 11 | for i in range(5): 283 | 12 | row = [] 284 | 14 | result.append(row) 285 | --> 15 | print_stack() 286 | 17 | for j in range(5): 287 | ``` 288 | 289 | `line.variable_ranges` is a list of RangeInLines for each Variable that appears at least partially in this line. The data attribute of the range is a pair `(variable, node)` where node is the particular AST node from the list `variable.nodes` that corresponds to this range. 290 | 291 | You can also use `line.token_ranges` (e.g. if you want to do your own syntax highlighting) or `line.executing_node_ranges` if you want to highlight the currently executing node identified by the [`executing`](https://github.com/alexmojaki/executing) library. Or if you want to make your own range from an AST node, use `line.range_from_node(node, data)`. See the docstrings for more info. 292 | 293 | ### Syntax highlighting with Pygments 294 | 295 | If you'd like pretty colored text without the work, you can let [Pygments](https://pygments.org/) do it for you. Just follow these steps: 296 | 297 | 1. `pip install pygments` separately as it's not a dependency of `stack_data`. 298 | 2. Create a pygments formatter object such as `HtmlFormatter` or `Terminal256Formatter`. 299 | 3. Pass the formatter to `Options` in the argument `pygments_formatter`. 300 | 4. Use `line.render(pygmented=True)` to get your formatted text. In this case you can't pass any markers to `render`. 301 | 302 | If you want, you can also highlight the executing node in the frame in combination with the pygments syntax highlighting. For this you will need: 303 | 304 | 1. A pygments style - either a style class or a string that names it. See the [documentation on styles](https://pygments.org/docs/styles/) and the [styles gallery](https://blog.yjl.im/2015/08/pygments-styles-gallery.html). 305 | 2. A modification to make to the style for the executing node, which is a string such as `"bold"` or `"bg:#ffff00"` (yellow background). See the [documentation on style rules](https://pygments.org/docs/styles/#style-rules). 306 | 3. Pass these two things to `stack_data.style_with_executing_node(style, modifier)` to get a new style class. 307 | 4. Pass the new style to your formatter when you create it. 308 | 309 | Note that this doesn't work with `TerminalFormatter` which just uses the basic ANSI colors and doesn't use the style passed to it in general. 310 | 311 | ## Getting the full stack 312 | 313 | Currently `print_stack()` doesn't actually print the stack, it just prints one frame. Instead of `frame_info = FrameInfo(frame, options)`, let's do this: 314 | 315 | ```python 316 | for frame_info in FrameInfo.stack_data(frame, options): 317 | ``` 318 | 319 | Now the output looks something like this: 320 | 321 | ``` 322 | at line 18 323 | ----------- 324 | 14 | for j in range(5): 325 | 15 | row.append(i * j) 326 | 16 | return result 327 | --> 18 | bar() 328 | 329 | bar at line 5 330 | ----------- 331 | 4 | def bar(): 332 | --> 5 | foo() 333 | 334 | foo at line 13 335 | ----------- 336 | 10 | for i in range(5): 337 | 11 | row = [] 338 | 12 | result.append(row) 339 | --> 13 | print_stack() 340 | 14 | for j in range(5): 341 | ``` 342 | 343 | However, just as `frame_info.lines` doesn't always yield `Line` objects, `FrameInfo.stack_data` doesn't always yield `FrameInfo` objects, and we must modify our code to handle that. Let's look at some different sample code: 344 | 345 | ```python 346 | def factorial(x): 347 | return x * factorial(x - 1) 348 | 349 | 350 | try: 351 | print(factorial(5)) 352 | except: 353 | print_stack() 354 | ``` 355 | 356 | In this code we've forgotten to include a base case in our `factorial` function so it will fail with a `RecursionError` and there'll be many frames with similar information. Similar to the built in Python traceback, `stack_data` avoids showing all of these frames. Instead you will get a `RepeatedFrames` object which summarises the information. See its docstring for more details. 357 | 358 | Here is our updated implementation: 359 | 360 | ```python 361 | def print_stack(): 362 | for frame_info in FrameInfo.stack_data(sys.exc_info()[2]): 363 | if isinstance(frame_info, FrameInfo): 364 | print(f"{frame_info.code.co_name} at line {frame_info.lineno}") 365 | print("-----------") 366 | for line in frame_info.lines: 367 | print(f"{'-->' if line.is_current else ' '} {line.lineno:4} | {line.render()}") 368 | 369 | for var in frame_info.variables: 370 | print(f"{var.name} = {repr(var.value)}") 371 | 372 | print() 373 | else: 374 | print(f"... {frame_info.description} ...\n") 375 | ``` 376 | 377 | And the output: 378 | 379 | ``` 380 | at line 9 381 | ----------- 382 | 4 | def factorial(x): 383 | 5 | return x * factorial(x - 1) 384 | 8 | try: 385 | --> 9 | print(factorial(5)) 386 | 10 | except: 387 | 388 | factorial at line 5 389 | ----------- 390 | 4 | def factorial(x): 391 | --> 5 | return x * factorial(x - 1) 392 | x = 5 393 | 394 | factorial at line 5 395 | ----------- 396 | 4 | def factorial(x): 397 | --> 5 | return x * factorial(x - 1) 398 | x = 4 399 | 400 | ... factorial at line 5 (996 times) ... 401 | 402 | factorial at line 5 403 | ----------- 404 | 4 | def factorial(x): 405 | --> 5 | return x * factorial(x - 1) 406 | x = -993 407 | ``` 408 | 409 | In addition to handling repeated frames, we've passed a traceback object to `FrameInfo.stack_data` instead of a frame. 410 | 411 | If you want, you can pass `collapse_repeated_frames=False` to `FrameInfo.stack_data` (not to `Options`) and it will just yield `FrameInfo` objects for the full stack. 412 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import inspect 3 | import os 4 | import re 5 | import sys 6 | import token 7 | from itertools import islice 8 | from pathlib import Path 9 | 10 | import pygments 11 | import pytest 12 | from executing import only 13 | # noinspection PyUnresolvedReferences 14 | from pygments.formatters.html import HtmlFormatter 15 | from pygments.lexers import Python3Lexer 16 | from stack_data import Options, Line, LINE_GAP, markers_from_ranges, Variable, RangeInLine, style_with_executing_node 17 | from stack_data import Source, FrameInfo 18 | from stack_data.utils import line_range 19 | 20 | samples_dir = Path(__file__).parent / "samples" 21 | pygments_version = tuple(map(int, pygments.__version__.split(".")[:2])) 22 | 23 | 24 | def test_lines_with_gaps(): 25 | lines = [] 26 | dedented = False 27 | 28 | def gather_lines(): 29 | frame = inspect.currentframe().f_back 30 | frame_info = FrameInfo(frame, options) 31 | assert repr(frame_info) == "FrameInfo({})".format(frame) 32 | lines[:] = [ 33 | line.render(strip_leading_indent=dedented) 34 | if isinstance(line, Line) else line 35 | for line in frame_info.lines 36 | ] 37 | 38 | def foo(): 39 | x = 1 40 | lst = [1] 41 | 42 | lst.insert(0, x) 43 | lst.append( 44 | [ 45 | 1, 46 | 2, 47 | 3, 48 | 4, 49 | 5, 50 | 6 51 | ][0]) 52 | gather_lines() 53 | lst += [99] 54 | return lst 55 | 56 | options = Options(include_signature=True) 57 | foo() 58 | assert lines == [ 59 | ' def foo():', 60 | LINE_GAP, 61 | ' lst = [1]', 62 | ' lst.insert(0, x)', 63 | ' lst.append(', 64 | ' [', 65 | ' 1,', 66 | LINE_GAP, 67 | ' 6', 68 | ' ][0])', 69 | ' gather_lines()', 70 | ' lst += [99]', 71 | ] 72 | 73 | options = Options() 74 | foo() 75 | assert lines == [ 76 | ' lst = [1]', 77 | ' lst.insert(0, x)', 78 | ' lst.append(', 79 | ' [', 80 | ' 1,', 81 | LINE_GAP, 82 | ' 6', 83 | ' ][0])', 84 | ' gather_lines()', 85 | ' lst += [99]', 86 | ] 87 | 88 | def foo(): 89 | gather_lines() 90 | 91 | foo() 92 | assert lines == [ 93 | ' def foo():', 94 | ' gather_lines()', 95 | ] 96 | 97 | def foo(): 98 | lst = [1] 99 | lst.insert(0, 2) 100 | lst.append( 101 | [ 102 | 1, 103 | 2, 104 | 3, 105 | gather_lines(), 106 | 5, 107 | 6 108 | ][0]) 109 | lst += [99] 110 | return lst 111 | 112 | foo() 113 | assert lines == [ 114 | ' def foo():', 115 | ' lst = [1]', 116 | ' lst.insert(0, 2)', 117 | ' lst.append(', 118 | ' [', 119 | ' 1,', 120 | ' 2,', 121 | ' 3,', 122 | ' gather_lines(),', 123 | ' 5,', 124 | ' 6', 125 | ' ][0])', 126 | ' lst += [99]' 127 | ] 128 | 129 | dedented = True 130 | 131 | foo() 132 | assert lines == [ 133 | 'def foo():', 134 | ' lst = [1]', 135 | ' lst.insert(0, 2)', 136 | ' lst.append(', 137 | ' [', 138 | ' 1,', 139 | ' 2,', 140 | ' 3,', 141 | ' gather_lines(),', 142 | ' 5,', 143 | ' 6', 144 | ' ][0])', 145 | ' lst += [99]' 146 | ] 147 | 148 | 149 | def test_markers(): 150 | options = Options(before=0, after=0) 151 | line = only(FrameInfo(inspect.currentframe(), options).lines) 152 | assert line.is_current 153 | assert re.match(r"") 161 | assert repr(LINE_GAP) == "LINE_GAP" 162 | 163 | assert '*'.join(t.string for t in line.tokens) == \ 164 | 'line*=*only*(*FrameInfo*(*inspect*.*currentframe*(*)*,*options*)*.*lines*)*\n' 165 | 166 | def convert_token_range(r): 167 | if r.data.type == token.NAME: 168 | return '[[', ']]' 169 | 170 | markers = markers_from_ranges(line.token_ranges, convert_token_range) 171 | assert line.render(markers) == \ 172 | '[[line]] = [[only]]([[FrameInfo]]([[inspect]].[[currentframe]](), [[options]]).[[lines]])' 173 | assert line.render(markers, strip_leading_indent=False) == \ 174 | ' [[line]] = [[only]]([[FrameInfo]]([[inspect]].[[currentframe]](), [[options]]).[[lines]])' 175 | 176 | def convert_variable_range(r): 177 | return '[[', ' of type {}]]'.format(r.data[0].value.__class__.__name__) 178 | 179 | markers = markers_from_ranges(line.variable_ranges, convert_variable_range) 180 | assert sorted(markers) == [ 181 | (4, True, '[['), 182 | (8, False, ' of type Line]]'), 183 | (50, True, '[['), 184 | (57, False, ' of type Options]]'), 185 | ] 186 | 187 | line.text += ' # < > " & done' 188 | assert line.render(markers) == \ 189 | '[[line of type Line]] = only(FrameInfo(inspect.currentframe(), [[options of type Options]]).lines)' \ 190 | ' # < > " & done' 191 | 192 | assert line.render(markers, escape_html=True) == \ 193 | '[[line of type Line]] = only(FrameInfo(inspect.currentframe(), [[options of type Options]]).lines)' \ 194 | ' # < > " & done' 195 | 196 | 197 | def test_invalid_converter(): 198 | def converter(_): 199 | return 1, 2 200 | 201 | ranges = [RangeInLine(0, 1, None)] 202 | with pytest.raises(TypeError): 203 | # noinspection PyTypeChecker 204 | markers_from_ranges(ranges, converter) 205 | 206 | 207 | def test_variables(): 208 | options = Options(before=1, after=0) 209 | assert repr(options) == ('Options(after=0, before=1, ' + 210 | 'blank_lines=,' + 211 | ' include_signature=False, ' + 212 | 'max_lines_per_piece=6, pygments_formatter=None)') 213 | 214 | def foo(arg, _arg2: str = None, *_args, **_kwargs): 215 | y = 123986 216 | str(y) 217 | x = {982347298304} 218 | str(x) 219 | return ( 220 | FrameInfo(inspect.currentframe(), options), 221 | arg, 222 | arg, 223 | )[0] 224 | 225 | frame_info = foo('this is arg') 226 | 227 | assert sum(line.is_current for line in frame_info.lines) == 1 228 | 229 | body = frame_info.scope.body 230 | 231 | tup = body[-1].value.value.elts 232 | call = tup[0] 233 | assert frame_info.executing.node == call 234 | assert frame_info.code == foo.__code__ 235 | assert frame_info.filename.endswith(frame_info.code.co_filename) 236 | assert frame_info.filename.endswith("test_core.py") 237 | assert os.path.isabs(frame_info.filename) 238 | expected_variables = [ 239 | Variable( 240 | name='_arg2', 241 | nodes=( 242 | frame_info.scope.args.args[1], 243 | ), 244 | value=None, 245 | ), 246 | Variable( 247 | name='_args', 248 | nodes=( 249 | frame_info.scope.args.vararg, 250 | ), 251 | value=(), 252 | ), 253 | Variable( 254 | name='_kwargs', 255 | nodes=( 256 | frame_info.scope.args.kwarg, 257 | ), 258 | value={}, 259 | ), 260 | Variable( 261 | name='arg', 262 | nodes=( 263 | tup[1], 264 | tup[2], 265 | frame_info.scope.args.args[0], 266 | ), 267 | value='this is arg', 268 | ), 269 | Variable( 270 | name='options', 271 | nodes=(call.args[1],), 272 | value=options, 273 | ), 274 | Variable( 275 | name='str(x)', 276 | nodes=( 277 | body[3].value, 278 | ), 279 | value='{982347298304}', 280 | ), 281 | Variable( 282 | name='str(y)', 283 | nodes=( 284 | body[1].value, 285 | ), 286 | value='123986', 287 | ), 288 | Variable( 289 | name='x', 290 | nodes=( 291 | body[2].targets[0], 292 | body[3].value.args[0], 293 | ), 294 | value={982347298304}, 295 | ), 296 | Variable( 297 | name='y', 298 | nodes=( 299 | body[0].targets[0], 300 | body[1].value.args[0], 301 | ), 302 | value=123986, 303 | ), 304 | ] 305 | expected_variables = [tuple(v) for v in expected_variables] 306 | variables = [tuple(v) for v in sorted(frame_info.variables)] 307 | assert expected_variables == variables 308 | 309 | assert ( 310 | sorted(frame_info.variables_in_executing_piece) == 311 | variables[3:5] 312 | ) 313 | 314 | assert ( 315 | sorted(frame_info.variables_in_lines) == 316 | [*variables[3:6], variables[7]] 317 | ) 318 | 319 | 320 | def test_pieces(): 321 | filename = samples_dir / "pieces.py" 322 | source = Source.for_filename(str(filename)) 323 | pieces = [ 324 | [ 325 | source.lines[i - 1] 326 | for i in piece 327 | ] 328 | for piece in source.pieces 329 | ] 330 | assert pieces == [ 331 | ['import math'], 332 | ['def foo(x=1, y=2):'], 333 | [' """', 334 | ' a docstring', 335 | ' """'], 336 | [' z = 0'], 337 | [' for i in range(5):'], 338 | [' z += i * x * math.sin(y)'], 339 | [' # comment1', 340 | ' # comment2'], 341 | [' z += math.copysign(', 342 | ' -1,', 343 | ' 2,', 344 | ' )'], 345 | [' for i in range(', 346 | ' 0,', 347 | ' 6', 348 | ' ):'], 349 | [' try:'], 350 | [' str(i)'], 351 | [' except:'], 352 | [' pass'], 353 | [' try:'], 354 | [' int(i)'], 355 | [' except (ValueError,', 356 | ' TypeError):'], 357 | [' pass'], 358 | [' finally:'], 359 | [' str("""', 360 | ' foo', 361 | ' """)'], 362 | [' str(f"""', 363 | ' {str(str)}', 364 | ' """)'], 365 | [' str(f"""', 366 | ' foo', 367 | ' {', 368 | ' str(', 369 | ' str', 370 | ' )', 371 | ' }', 372 | ' bar', 373 | ' {str(str)}', 374 | ' baz', 375 | ' {', 376 | ' str(', 377 | ' str', 378 | ' )', 379 | ' }', 380 | ' spam', 381 | ' """)'], 382 | ['def foo2(', 383 | ' x=1,', 384 | ' y=2,', '):'], 385 | [' while 9:'], 386 | [' while (', 387 | ' 9 + 9', 388 | ' ):'], 389 | [' if 1:'], 390 | [' pass'], 391 | [' elif 2:'], 392 | [' pass'], 393 | [' elif (', 394 | ' 3 + 3', 395 | ' ):'], 396 | [' pass'], 397 | [' else:'], 398 | [' pass'], 399 | ['class Foo(object):'], 400 | [' @property', 401 | ' def foo(self):'], 402 | [' return 3'], 403 | ['# noinspection PyTrailingSemicolon'], 404 | ['def semicolons():'], 405 | [' if 1:'], 406 | [' print(1,', 407 | ' 2); print(3,', 408 | ' 4); print(5,', 409 | ' 6)'], 410 | [' if 2:'], 411 | [' print(1,', 412 | ' 2); print(3, 4); print(5,', 413 | ' 6)'], 414 | [' print(1, 2); print(3,', 415 | ' 4); print(5, 6)'], 416 | [' print(1, 2);print(3, 4);print(5, 6)'] 417 | ] 418 | 419 | 420 | def check_skipping_frames(collapse: bool): 421 | def factorial(n): 422 | if n <= 1: 423 | return 1 / 0 # exception lineno 424 | return n * foo(n - 1) # factorial lineno 425 | 426 | def foo(n): 427 | return factorial(n) # foo lineno 428 | 429 | try: 430 | factorial(20) # check_skipping_frames lineno 431 | except Exception as e: 432 | # tb = sys.exc_info()[2] 433 | tb = e.__traceback__ 434 | result = [] 435 | for x in FrameInfo.stack_data(tb, collapse_repeated_frames=collapse): 436 | if isinstance(x, FrameInfo): 437 | result.append((x.code, x.lineno)) 438 | else: 439 | result.append(repr(x)) 440 | source = Source.for_filename(__file__) 441 | linenos = {} 442 | for lineno, line in enumerate(source.lines): 443 | match = re.search(r" # (\w+) lineno", line) 444 | if match: 445 | linenos[match.group(1)] = lineno + 1 446 | 447 | def simple_frame(func): 448 | return func.__code__, linenos[func.__name__] 449 | 450 | if collapse: 451 | middle = [ 452 | simple_frame(factorial), 453 | simple_frame(foo), 454 | simple_frame(factorial), 455 | simple_frame(foo), 456 | (".factorial at line {factorial} (16 times), " 458 | "check_skipping_frames..foo at line {foo} (16 times)>" 459 | ).format(**linenos), 460 | simple_frame(factorial), 461 | simple_frame(foo), 462 | ] 463 | else: 464 | middle = [ 465 | *([ 466 | simple_frame(factorial), 467 | simple_frame(foo), 468 | ] * 19) 469 | ] 470 | 471 | assert result == [ 472 | simple_frame(check_skipping_frames), 473 | *middle, 474 | (factorial.__code__, linenos["exception"]), 475 | ] 476 | 477 | 478 | def test_skipping_frames(): 479 | check_skipping_frames(True) 480 | check_skipping_frames(False) 481 | 482 | 483 | def sys_modules_sources(): 484 | for module in list(sys.modules.values()): 485 | try: 486 | filename = inspect.getsourcefile(module) 487 | except (TypeError, AttributeError): 488 | continue 489 | 490 | if not filename: 491 | continue 492 | 493 | filename = os.path.abspath(filename) 494 | print(filename) 495 | source = Source.for_filename(filename) 496 | if not source.tree: 497 | continue 498 | 499 | yield source 500 | 501 | 502 | def test_sys_modules(): 503 | modules = sys_modules_sources() 504 | if not os.environ.get('STACK_DATA_SLOW_TESTS'): 505 | modules = islice(modules, 0, 3) 506 | 507 | for source in modules: 508 | check_pieces(source) 509 | check_pygments_tokens(source) 510 | 511 | 512 | def check_pieces(source): 513 | pieces = source.pieces 514 | 515 | assert pieces == sorted(pieces, key=lambda p: (p.start, p.stop)) 516 | 517 | stmts = sorted({ 518 | source.line_range(node) 519 | for node in ast.walk(source.tree) 520 | if isinstance(node, ast.stmt) 521 | if not isinstance(getattr(node, 'body', None), list) 522 | }) 523 | if not stmts: 524 | return 525 | 526 | stmts_iter = iter(stmts) 527 | stmt = next(stmts_iter) 528 | for piece in pieces: 529 | contains_stmt = stmt[0] <= piece.start < piece.stop <= stmt[1] 530 | before_stmt = piece.start < piece.stop <= stmt[0] < stmt[1] 531 | assert contains_stmt ^ before_stmt 532 | if contains_stmt: 533 | try: 534 | stmt = next(stmts_iter) 535 | except StopIteration: 536 | break 537 | 538 | blank_linenos = set(range(1, len(source.lines) + 1)).difference(*pieces) 539 | 540 | for lineno in blank_linenos: 541 | assert not source.lines[lineno - 1].strip(), lineno 542 | 543 | 544 | def check_pygments_tokens(source): 545 | lexer = Python3Lexer(stripnl=False, ensurenl=False) 546 | pygments_tokens = [value for ttype, value in pygments.lex(source.text, lexer)] 547 | assert ''.join(pygments_tokens) == source.text 548 | 549 | 550 | def test_invalid_source(): 551 | filename = str(samples_dir / "not_code.txt") 552 | source = Source.for_filename(filename) 553 | assert not source.tree 554 | assert not hasattr(source, "tokens_by_lineno") 555 | 556 | 557 | def test_absolute_filename(): 558 | sys.path.append(str(samples_dir)) 559 | short_filename = "to_exec.py" 560 | full_filename = str(samples_dir / short_filename) 561 | source = Source.for_filename(short_filename) 562 | names = {} 563 | code = compile(source.text, short_filename, "exec") 564 | exec(code, names) 565 | frame_info = names["frame_info"] 566 | assert frame_info.source is source 567 | assert frame_info.code is code 568 | assert code.co_filename == source.filename == short_filename 569 | assert frame_info.filename == full_filename 570 | 571 | 572 | @pytest.mark.parametrize("expected", 573 | [ 574 | r".c { color: #(999999|ababab); font-style: italic }", 575 | r".err { color: #a61717; background-color: #e3d2d2 }", 576 | r".c-ExecutingNode { color: #(999999|ababab); font-style: italic; background-color: #ffff00 }", 577 | r".err-ExecutingNode { color: #a61717; background-color: #ffff00 }", 578 | ] 579 | ) 580 | def test_executing_style_defs(expected): 581 | style = style_with_executing_node("native", "bg:#ffff00") 582 | formatter = HtmlFormatter(style=style) 583 | style_defs = formatter.get_style_defs() 584 | 585 | assert re.search(expected, style_defs) 586 | 587 | 588 | def test_example(): 589 | from .samples.example import bar 590 | result = bar() 591 | print(result) 592 | assert result == """\ 593 | bar at line 27 594 | -------------- 595 | 25 | def bar(): 596 | 26 | names = {} 597 | 27 > exec("result = foo()", globals(), names) 598 | 28 | return names["result"] 599 | names = {} 600 | 601 | at line 1 602 | ------------------ 603 | 604 | foo at line 20 605 | -------------- 606 | 6 | def foo(): 607 | (...) 608 | 8 | lst = [1] 609 | 10 | lst.insert(0, x) 610 | 11 | lst.append( 611 | 12 | [ 612 | 13 | 1, 613 | (...) 614 | 18 | 6 615 | 19 | ][0]) 616 | 20 > result = print_stack( 617 | 21 | ) 618 | 22 | return result 619 | [ 620 | 1, 621 | 2, 622 | 3, 623 | 4, 624 | 5, 625 | 6 626 | ][0] = 1 627 | lst = [1, 1, 1] 628 | x = 1 629 | 630 | """ 631 | 632 | 633 | @pytest.mark.skipif(pygments_version < (2, 14), reason="Different output in older Pygments") 634 | def test_pygments_example(): 635 | from .samples.pygments_example import bar 636 | result = bar() 637 | print(result) 638 | assert result == """\ 639 | Terminal256Formatter native: 640 | 641 | 13 | \x1b[38;5;70;01mdef\x1b[39;00m\x1b[38;5;252m \x1b[39m\x1b[38;5;75mbar\x1b[39m\x1b[38;5;252m(\x1b[39m\x1b[38;5;252m)\x1b[39m\x1b[38;5;252m:\x1b[39m 642 | 14 | \x1b[38;5;252m \x1b[39m\x1b[38;5;252mx\x1b[39m\x1b[38;5;252m \x1b[39m\x1b[38;5;252m=\x1b[39m\x1b[38;5;252m \x1b[39m\x1b[38;5;75m1\x1b[39m 643 | 15 | \x1b[38;5;252m \x1b[39m\x1b[38;5;38mstr\x1b[39m\x1b[38;5;252m(\x1b[39m\x1b[38;5;252mx\x1b[39m\x1b[38;5;252m)\x1b[39m 644 | 17 | \x1b[38;5;252m \x1b[39m\x1b[38;5;214m@deco\x1b[39m 645 | 18 | \x1b[38;5;252m \x1b[39m\x1b[38;5;70;01mdef\x1b[39;00m\x1b[38;5;252m \x1b[39m\x1b[38;5;75mfoo\x1b[39m\x1b[38;5;252m(\x1b[39m\x1b[38;5;252m)\x1b[39m\x1b[38;5;252m:\x1b[39m 646 | 19 | \x1b[38;5;252m \x1b[39m\x1b[38;5;70;01mpass\x1b[39;00m 647 | ----- 648 | 25 | \x1b[38;5;70;01mdef\x1b[39;00m\x1b[38;5;252m \x1b[39m\x1b[38;5;75mdeco\x1b[39m\x1b[38;5;252m(\x1b[39m\x1b[38;5;252mf\x1b[39m\x1b[38;5;252m)\x1b[39m\x1b[38;5;252m:\x1b[39m 649 | 26 | \x1b[38;5;252m \x1b[39m\x1b[38;5;252mf\x1b[39m\x1b[38;5;252m.\x1b[39m\x1b[38;5;252mresult\x1b[39m\x1b[38;5;252m \x1b[39m\x1b[38;5;252m=\x1b[39m\x1b[38;5;252m \x1b[39m\x1b[38;5;252mprint_stack\x1b[39m\x1b[38;5;252m(\x1b[39m\x1b[38;5;252m)\x1b[39m 650 | 27 | \x1b[38;5;252m \x1b[39m\x1b[38;5;70;01mreturn\x1b[39;00m\x1b[38;5;252m \x1b[39m\x1b[38;5;252mf\x1b[39m 651 | ----- 652 | 653 | ==================== 654 | 655 | Terminal256Formatter .NewStyle\'>: 656 | 657 | 13 | \x1b[38;5;70;01mdef\x1b[39;00m\x1b[38;5;252m \x1b[39m\x1b[38;5;75mbar\x1b[39m\x1b[38;5;252m(\x1b[39m\x1b[38;5;252m)\x1b[39m\x1b[38;5;252m:\x1b[39m 658 | 14 | \x1b[38;5;252m \x1b[39m\x1b[38;5;252mx\x1b[39m\x1b[38;5;252m \x1b[39m\x1b[38;5;252m=\x1b[39m\x1b[38;5;252m \x1b[39m\x1b[38;5;75m1\x1b[39m 659 | 15 | \x1b[38;5;252m \x1b[39m\x1b[38;5;38mstr\x1b[39m\x1b[38;5;252m(\x1b[39m\x1b[38;5;252mx\x1b[39m\x1b[38;5;252m)\x1b[39m 660 | 17 | \x1b[38;5;252;48;5;58m \x1b[39;49m\x1b[38;5;214;48;5;58m@deco\x1b[39;49m 661 | 18 | \x1b[38;5;252;48;5;58m \x1b[39;49m\x1b[38;5;70;48;5;58;01mdef\x1b[39;49;00m\x1b[38;5;252;48;5;58m \x1b[39;49m\x1b[38;5;75;48;5;58mfoo\x1b[39;49m\x1b[38;5;252;48;5;58m(\x1b[39;49m\x1b[38;5;252;48;5;58m)\x1b[39;49m\x1b[38;5;252;48;5;58m:\x1b[39;49m 662 | 19 | \x1b[38;5;252;48;5;58m \x1b[39;49m\x1b[38;5;70;48;5;58;01mpass\x1b[39;49;00m 663 | ----- 664 | 25 | \x1b[38;5;70;01mdef\x1b[39;00m\x1b[38;5;252m \x1b[39m\x1b[38;5;75mdeco\x1b[39m\x1b[38;5;252m(\x1b[39m\x1b[38;5;252mf\x1b[39m\x1b[38;5;252m)\x1b[39m\x1b[38;5;252m:\x1b[39m 665 | 26 | \x1b[38;5;252m \x1b[39m\x1b[38;5;252mf\x1b[39m\x1b[38;5;252m.\x1b[39m\x1b[38;5;252mresult\x1b[39m\x1b[38;5;252m \x1b[39m\x1b[38;5;252m=\x1b[39m\x1b[38;5;252m \x1b[39m\x1b[38;5;252;48;5;58mprint_stack\x1b[39;49m\x1b[38;5;252;48;5;58m(\x1b[39;49m\x1b[38;5;252;48;5;58m)\x1b[39;49m 666 | 27 | \x1b[38;5;252m \x1b[39m\x1b[38;5;70;01mreturn\x1b[39;00m\x1b[38;5;252m \x1b[39m\x1b[38;5;252mf\x1b[39m 667 | ----- 668 | 669 | ==================== 670 | 671 | TerminalFormatter native: 672 | 673 | 13 | \x1b[34mdef\x1b[39;49;00m \x1b[32mbar\x1b[39;49;00m():\x1b[37m\x1b[39;49;00m 674 | 14 | x = \x1b[34m1\x1b[39;49;00m\x1b[37m\x1b[39;49;00m 675 | 15 | \x1b[36mstr\x1b[39;49;00m(x)\x1b[37m\x1b[39;49;00m 676 | 17 | \x1b[90m@deco\x1b[39;49;00m\x1b[37m\x1b[39;49;00m 677 | 18 | \x1b[34mdef\x1b[39;49;00m \x1b[32mfoo\x1b[39;49;00m():\x1b[37m\x1b[39;49;00m 678 | 19 | \x1b[34mpass\x1b[39;49;00m\x1b[37m\x1b[39;49;00m 679 | ----- 680 | 25 | \x1b[34mdef\x1b[39;49;00m \x1b[32mdeco\x1b[39;49;00m(f):\x1b[37m\x1b[39;49;00m 681 | 26 | f.result = print_stack()\x1b[37m\x1b[39;49;00m 682 | 27 | \x1b[34mreturn\x1b[39;49;00m f\x1b[37m\x1b[39;49;00m 683 | ----- 684 | 685 | ==================== 686 | 687 | TerminalFormatter .NewStyle\'>: 688 | 689 | 13 | \x1b[34mdef\x1b[39;49;00m \x1b[32mbar\x1b[39;49;00m():\x1b[37m\x1b[39;49;00m 690 | 14 | x = \x1b[34m1\x1b[39;49;00m\x1b[37m\x1b[39;49;00m 691 | 15 | \x1b[36mstr\x1b[39;49;00m(x)\x1b[37m\x1b[39;49;00m 692 | 17 | \x1b[90m@deco\x1b[39;49;00m\x1b[37m\x1b[39;49;00m 693 | 18 | \x1b[34mdef\x1b[39;49;00m \x1b[32mfoo\x1b[39;49;00m():\x1b[37m\x1b[39;49;00m 694 | 19 | \x1b[34mpass\x1b[39;49;00m\x1b[37m\x1b[39;49;00m 695 | ----- 696 | 25 | \x1b[34mdef\x1b[39;49;00m \x1b[32mdeco\x1b[39;49;00m(f):\x1b[37m\x1b[39;49;00m 697 | 26 | f.result = print_stack()\x1b[37m\x1b[39;49;00m 698 | 27 | \x1b[34mreturn\x1b[39;49;00m f\x1b[37m\x1b[39;49;00m 699 | ----- 700 | 701 | ==================== 702 | 703 | TerminalTrueColorFormatter native: 704 | 705 | 13 | \x1b[38;2;110;191;38;01mdef\x1b[39;00m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;113;173;255mbar\x1b[39m\x1b[38;2;208;208;208m(\x1b[39m\x1b[38;2;208;208;208m)\x1b[39m\x1b[38;2;208;208;208m:\x1b[39m 706 | 14 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208mx\x1b[39m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208m=\x1b[39m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;81;178;253m1\x1b[39m 707 | 15 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;47;188;205mstr\x1b[39m\x1b[38;2;208;208;208m(\x1b[39m\x1b[38;2;208;208;208mx\x1b[39m\x1b[38;2;208;208;208m)\x1b[39m 708 | 17 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;255;165;0m@deco\x1b[39m 709 | 18 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;110;191;38;01mdef\x1b[39;00m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;113;173;255mfoo\x1b[39m\x1b[38;2;208;208;208m(\x1b[39m\x1b[38;2;208;208;208m)\x1b[39m\x1b[38;2;208;208;208m:\x1b[39m 710 | 19 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;110;191;38;01mpass\x1b[39;00m 711 | ----- 712 | 25 | \x1b[38;2;110;191;38;01mdef\x1b[39;00m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;113;173;255mdeco\x1b[39m\x1b[38;2;208;208;208m(\x1b[39m\x1b[38;2;208;208;208mf\x1b[39m\x1b[38;2;208;208;208m)\x1b[39m\x1b[38;2;208;208;208m:\x1b[39m 713 | 26 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208mf\x1b[39m\x1b[38;2;208;208;208m.\x1b[39m\x1b[38;2;208;208;208mresult\x1b[39m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208m=\x1b[39m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208mprint_stack\x1b[39m\x1b[38;2;208;208;208m(\x1b[39m\x1b[38;2;208;208;208m)\x1b[39m 714 | 27 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;110;191;38;01mreturn\x1b[39;00m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208mf\x1b[39m 715 | ----- 716 | 717 | ==================== 718 | 719 | TerminalTrueColorFormatter .NewStyle\'>: 720 | 721 | 13 | \x1b[38;2;110;191;38;01mdef\x1b[39;00m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;113;173;255mbar\x1b[39m\x1b[38;2;208;208;208m(\x1b[39m\x1b[38;2;208;208;208m)\x1b[39m\x1b[38;2;208;208;208m:\x1b[39m 722 | 14 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208mx\x1b[39m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208m=\x1b[39m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;81;178;253m1\x1b[39m 723 | 15 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;47;188;205mstr\x1b[39m\x1b[38;2;208;208;208m(\x1b[39m\x1b[38;2;208;208;208mx\x1b[39m\x1b[38;2;208;208;208m)\x1b[39m 724 | 17 | \x1b[38;2;208;208;208;48;2;68;68;0m \x1b[39;49m\x1b[38;2;255;165;0;48;2;68;68;0m@deco\x1b[39;49m 725 | 18 | \x1b[38;2;208;208;208;48;2;68;68;0m \x1b[39;49m\x1b[38;2;110;191;38;48;2;68;68;0;01mdef\x1b[39;49;00m\x1b[38;2;208;208;208;48;2;68;68;0m \x1b[39;49m\x1b[38;2;113;173;255;48;2;68;68;0mfoo\x1b[39;49m\x1b[38;2;208;208;208;48;2;68;68;0m(\x1b[39;49m\x1b[38;2;208;208;208;48;2;68;68;0m)\x1b[39;49m\x1b[38;2;208;208;208;48;2;68;68;0m:\x1b[39;49m 726 | 19 | \x1b[38;2;208;208;208;48;2;68;68;0m \x1b[39;49m\x1b[38;2;110;191;38;48;2;68;68;0;01mpass\x1b[39;49;00m 727 | ----- 728 | 25 | \x1b[38;2;110;191;38;01mdef\x1b[39;00m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;113;173;255mdeco\x1b[39m\x1b[38;2;208;208;208m(\x1b[39m\x1b[38;2;208;208;208mf\x1b[39m\x1b[38;2;208;208;208m)\x1b[39m\x1b[38;2;208;208;208m:\x1b[39m 729 | 26 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208mf\x1b[39m\x1b[38;2;208;208;208m.\x1b[39m\x1b[38;2;208;208;208mresult\x1b[39m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208m=\x1b[39m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208;48;2;68;68;0mprint_stack\x1b[39;49m\x1b[38;2;208;208;208;48;2;68;68;0m(\x1b[39;49m\x1b[38;2;208;208;208;48;2;68;68;0m)\x1b[39;49m 730 | 27 | \x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;110;191;38;01mreturn\x1b[39;00m\x1b[38;2;208;208;208m \x1b[39m\x1b[38;2;208;208;208mf\x1b[39m 731 | ----- 732 | 733 | ==================== 734 | 735 | HtmlFormatter native: 736 | 737 | 13 | def bar(): 738 | 14 | x = 1 739 | 15 | str(x) 740 | 17 | @deco 741 | 18 | def foo(): 742 | 19 | pass 743 | ----- 744 | 25 | def deco(f): 745 | 26 | f.result = print_stack() 746 | 27 | return f 747 | ----- 748 | 749 | ==================== 750 | 751 | HtmlFormatter .NewStyle\'>: 752 | 753 | 13 | def bar(): 754 | 14 | x = 1 755 | 15 | str(x) 756 | 17 | @deco 757 | 18 | def foo(): 758 | 19 | pass 759 | ----- 760 | 25 | def deco(f): 761 | 26 | f.result = print_stack() 762 | 27 | return f 763 | ----- 764 | 765 | ==================== 766 | 767 | """ 768 | -------------------------------------------------------------------------------- /stack_data/core.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import html 3 | import os 4 | import sys 5 | from collections import defaultdict, Counter 6 | from enum import Enum 7 | from textwrap import dedent 8 | from types import FrameType, CodeType, TracebackType 9 | from typing import ( 10 | Iterator, List, Tuple, Optional, NamedTuple, 11 | Any, Iterable, Callable, Union, 12 | Sequence) 13 | from typing import Mapping 14 | 15 | import executing 16 | from asttokens.util import Token 17 | from executing import only 18 | from pure_eval import Evaluator, is_expression_interesting 19 | from stack_data.utils import ( 20 | truncate, unique_in_order, line_range, 21 | frame_and_lineno, iter_stack, collapse_repeated, group_by_key_func, 22 | cached_property, is_frame, _pygmented_with_ranges, assert_) 23 | 24 | RangeInLine = NamedTuple('RangeInLine', 25 | [('start', int), 26 | ('end', int), 27 | ('data', Any)]) 28 | RangeInLine.__doc__ = """ 29 | Represents a range of characters within one line of source code, 30 | and some associated data. 31 | 32 | Typically this will be converted to a pair of markers by markers_from_ranges. 33 | """ 34 | 35 | MarkerInLine = NamedTuple('MarkerInLine', 36 | [('position', int), 37 | ('is_start', bool), 38 | ('string', str)]) 39 | MarkerInLine.__doc__ = """ 40 | A string that is meant to be inserted at a given position in a line of source code. 41 | For example, this could be an ANSI code or the opening or closing of an HTML tag. 42 | is_start should be True if this is the first of a pair such as the opening of an HTML tag. 43 | This will help to sort and insert markers correctly. 44 | 45 | Typically this would be created from a RangeInLine by markers_from_ranges. 46 | Then use Line.render to insert the markers correctly. 47 | """ 48 | 49 | 50 | class BlankLines(Enum): 51 | """The values are intended to correspond to the following behaviour: 52 | HIDDEN: blank lines are not shown in the output 53 | VISIBLE: blank lines are visible in the output 54 | SINGLE: any consecutive blank lines are shown as a single blank line 55 | in the output. This option requires the line number to be shown. 56 | For a single blank line, the corresponding line number is shown. 57 | Two or more consecutive blank lines are shown as a single blank 58 | line in the output with a custom string shown instead of a 59 | specific line number. 60 | """ 61 | HIDDEN = 1 62 | VISIBLE = 2 63 | SINGLE=3 64 | 65 | class Variable( 66 | NamedTuple('_Variable', 67 | [('name', str), 68 | ('nodes', Sequence[ast.AST]), 69 | ('value', Any)]) 70 | ): 71 | """ 72 | An expression that appears one or more times in source code and its associated value. 73 | This will usually be a variable but it can be any expression evaluated by pure_eval. 74 | - name is the source text of the expression. 75 | - nodes is a list of equivalent nodes representing the same expression. 76 | - value is the safely evaluated value of the expression. 77 | """ 78 | __hash__ = object.__hash__ 79 | __eq__ = object.__eq__ 80 | 81 | 82 | class Source(executing.Source): 83 | """ 84 | The source code of a single file and associated metadata. 85 | 86 | In addition to the attributes from the base class executing.Source, 87 | if .tree is not None, meaning this is valid Python code, objects have: 88 | - pieces: a list of Piece objects 89 | - tokens_by_lineno: a defaultdict(list) mapping line numbers to lists of tokens. 90 | 91 | Don't construct this class. Get an instance from frame_info.source. 92 | """ 93 | 94 | @cached_property 95 | def pieces(self) -> List[range]: 96 | if not self.tree: 97 | return [ 98 | range(i, i + 1) 99 | for i in range(1, len(self.lines) + 1) 100 | ] 101 | return list(self._clean_pieces()) 102 | 103 | @cached_property 104 | def tokens_by_lineno(self) -> Mapping[int, List[Token]]: 105 | if not self.tree: 106 | raise AttributeError("This file doesn't contain valid Python, so .tokens_by_lineno doesn't exist") 107 | return group_by_key_func( 108 | self.asttokens().tokens, 109 | lambda tok: tok.start[0], 110 | ) 111 | 112 | def _clean_pieces(self) -> Iterator[range]: 113 | pieces = self._raw_split_into_pieces(self.tree, 1, len(self.lines) + 1) 114 | pieces = [ 115 | (start, end) 116 | for (start, end) in pieces 117 | if end > start 118 | ] 119 | 120 | # Combine overlapping pieces, i.e. consecutive pieces where the end of the first 121 | # is greater than the start of the second. 122 | # This can happen when two statements are on the same line separated by a semicolon. 123 | new_pieces = pieces[:1] 124 | for (start, end) in pieces[1:]: 125 | (last_start, last_end) = new_pieces[-1] 126 | if start < last_end: 127 | assert start == last_end - 1 128 | assert ';' in self.lines[start - 1] 129 | new_pieces[-1] = (last_start, end) 130 | else: 131 | new_pieces.append((start, end)) 132 | pieces = new_pieces 133 | 134 | starts = [start for start, end in pieces[1:]] 135 | ends = [end for start, end in pieces[:-1]] 136 | if starts != ends: 137 | joins = list(map(set, zip(starts, ends))) 138 | mismatches = [s for s in joins if len(s) > 1] 139 | raise AssertionError("Pieces mismatches: %s" % mismatches) 140 | 141 | def is_blank(i): 142 | try: 143 | return not self.lines[i - 1].strip() 144 | except IndexError: 145 | return False 146 | 147 | for start, end in pieces: 148 | while is_blank(start): 149 | start += 1 150 | while is_blank(end - 1): 151 | end -= 1 152 | if start < end: 153 | yield range(start, end) 154 | 155 | def _raw_split_into_pieces( 156 | self, 157 | stmt: ast.AST, 158 | start: int, 159 | end: int, 160 | ) -> Iterator[Tuple[int, int]]: 161 | for name, body in ast.iter_fields(stmt): 162 | if ( 163 | isinstance(body, list) and body and 164 | isinstance(body[0], (ast.stmt, ast.ExceptHandler, getattr(ast, 'match_case', ()))) 165 | ): 166 | for rang, group in sorted(group_by_key_func(body, self.line_range).items()): 167 | sub_stmt = group[0] 168 | for inner_start, inner_end in self._raw_split_into_pieces(sub_stmt, *rang): 169 | if start < inner_start: 170 | yield start, inner_start 171 | if inner_start < inner_end: 172 | yield inner_start, inner_end 173 | start = inner_end 174 | 175 | yield start, end 176 | 177 | def line_range(self, node: ast.AST) -> Tuple[int, int]: 178 | return line_range(self.asttext(), node) 179 | 180 | 181 | class Options: 182 | """ 183 | Configuration for FrameInfo, either in the constructor or the .stack_data classmethod. 184 | These all determine which Lines and gaps are produced by FrameInfo.lines. 185 | 186 | before and after are the number of pieces of context to include in a frame 187 | in addition to the executing piece. 188 | 189 | include_signature is whether to include the function signature as a piece in a frame. 190 | 191 | If a piece (other than the executing piece) has more than max_lines_per_piece lines, 192 | it will be truncated with a gap in the middle. 193 | """ 194 | def __init__( 195 | self, *, 196 | before: int = 3, 197 | after: int = 1, 198 | include_signature: bool = False, 199 | max_lines_per_piece: int = 6, 200 | pygments_formatter=None, 201 | blank_lines = BlankLines.HIDDEN 202 | ): 203 | self.before = before 204 | self.after = after 205 | self.include_signature = include_signature 206 | self.max_lines_per_piece = max_lines_per_piece 207 | self.pygments_formatter = pygments_formatter 208 | self.blank_lines = blank_lines 209 | 210 | def __repr__(self): 211 | keys = sorted(self.__dict__) 212 | items = ("{}={!r}".format(k, self.__dict__[k]) for k in keys) 213 | return "{}({})".format(type(self).__name__, ", ".join(items)) 214 | 215 | 216 | class LineGap(object): 217 | """ 218 | A singleton representing one or more lines of source code that were skipped 219 | in FrameInfo.lines. 220 | 221 | LINE_GAP can be created in two ways: 222 | - by truncating a piece of context that's too long. 223 | - immediately after the signature piece if Options.include_signature is true 224 | and the following piece isn't already part of the included pieces. 225 | """ 226 | def __repr__(self): 227 | return "LINE_GAP" 228 | 229 | 230 | LINE_GAP = LineGap() 231 | 232 | 233 | class BlankLineRange: 234 | """ 235 | Records the line number range for blank lines gaps between pieces. 236 | For a single blank line, begin_lineno == end_lineno. 237 | """ 238 | def __init__(self, begin_lineno: int, end_lineno: int): 239 | self.begin_lineno = begin_lineno 240 | self.end_lineno = end_lineno 241 | 242 | 243 | class Line(object): 244 | """ 245 | A single line of source code for a particular stack frame. 246 | 247 | Typically this is obtained from FrameInfo.lines. 248 | Since that list may also contain LINE_GAP, you should first check 249 | that this is really a Line before using it. 250 | 251 | Attributes: 252 | - frame_info 253 | - lineno: the 1-based line number within the file 254 | - text: the raw source of this line. For displaying text, see .render() instead. 255 | - leading_indent: the number of leading spaces that should probably be stripped. 256 | This attribute is set within FrameInfo.lines. If you construct this class 257 | directly you should probably set it manually (at least to 0). 258 | - is_current: whether this is the line currently being executed by the interpreter 259 | within this frame. 260 | - tokens: a list of source tokens in this line 261 | 262 | There are several helpers for constructing RangeInLines which can be converted to markers 263 | using markers_from_ranges which can be passed to .render(): 264 | - token_ranges 265 | - variable_ranges 266 | - executing_node_ranges 267 | - range_from_node 268 | """ 269 | def __init__( 270 | self, 271 | frame_info: 'FrameInfo', 272 | lineno: int, 273 | ): 274 | self.frame_info = frame_info 275 | self.lineno = lineno 276 | self.text = frame_info.source.lines[lineno - 1] # type: str 277 | self.leading_indent = None # type: Optional[int] 278 | 279 | def __repr__(self): 280 | return "<{self.__class__.__name__} {self.lineno} (current={self.is_current}) " \ 281 | "{self.text!r} of {self.frame_info.filename}>".format(self=self) 282 | 283 | @property 284 | def is_current(self) -> bool: 285 | """ 286 | Whether this is the line currently being executed by the interpreter 287 | within this frame. 288 | """ 289 | return self.lineno == self.frame_info.lineno 290 | 291 | @property 292 | def tokens(self) -> List[Token]: 293 | """ 294 | A list of source tokens in this line. 295 | The tokens are Token objects from asttokens: 296 | https://asttokens.readthedocs.io/en/latest/api-index.html#asttokens.util.Token 297 | """ 298 | return self.frame_info.source.tokens_by_lineno[self.lineno] 299 | 300 | @cached_property 301 | def token_ranges(self) -> List[RangeInLine]: 302 | """ 303 | A list of RangeInLines for each token in .tokens, 304 | where range.data is a Token object from asttokens: 305 | https://asttokens.readthedocs.io/en/latest/api-index.html#asttokens.util.Token 306 | """ 307 | return [ 308 | RangeInLine( 309 | token.start[1], 310 | token.end[1], 311 | token, 312 | ) 313 | for token in self.tokens 314 | ] 315 | 316 | @cached_property 317 | def variable_ranges(self) -> List[RangeInLine]: 318 | """ 319 | A list of RangeInLines for each Variable that appears at least partially in this line. 320 | The data attribute of the range is a pair (variable, node) where node is the particular 321 | AST node from the list variable.nodes that corresponds to this range. 322 | """ 323 | return [ 324 | self.range_from_node(node, (variable, node)) 325 | for variable, node in self.frame_info.variables_by_lineno[self.lineno] 326 | ] 327 | 328 | @cached_property 329 | def executing_node_ranges(self) -> List[RangeInLine]: 330 | """ 331 | A list of one or zero RangeInLines for the executing node of this frame. 332 | The list will have one element if the node can be found and it overlaps this line. 333 | """ 334 | return self._raw_executing_node_ranges( 335 | self.frame_info._executing_node_common_indent 336 | ) 337 | 338 | def _raw_executing_node_ranges(self, common_indent=0) -> List[RangeInLine]: 339 | ex = self.frame_info.executing 340 | node = ex.node 341 | if node: 342 | rang = self.range_from_node(node, ex, common_indent) 343 | if rang: 344 | return [rang] 345 | return [] 346 | 347 | def range_from_node( 348 | self, node: ast.AST, data: Any, common_indent: int = 0 349 | ) -> Optional[RangeInLine]: 350 | """ 351 | If the given node overlaps with this line, return a RangeInLine 352 | with the correct start and end and the given data. 353 | Otherwise, return None. 354 | """ 355 | atext = self.frame_info.source.asttext() 356 | (start, range_start), (end, range_end) = atext.get_text_positions(node, padded=False) 357 | 358 | if not (start <= self.lineno <= end): 359 | return None 360 | 361 | if start != self.lineno: 362 | range_start = common_indent 363 | 364 | if end != self.lineno: 365 | range_end = len(self.text) 366 | 367 | if range_start == range_end == 0: 368 | # This is an empty line. If it were included, it would result 369 | # in a value of zero for the common indentation assigned to 370 | # a block of code. 371 | return None 372 | 373 | return RangeInLine(range_start, range_end, data) 374 | 375 | def render( 376 | self, 377 | markers: Iterable[MarkerInLine] = (), 378 | *, 379 | strip_leading_indent: bool = True, 380 | pygmented: bool = False, 381 | escape_html: bool = False 382 | ) -> str: 383 | """ 384 | Produces a string for display consisting of .text 385 | with the .strings of each marker inserted at the correct positions. 386 | If strip_leading_indent is true (the default) then leading spaces 387 | common to all lines in this frame will be excluded. 388 | """ 389 | if pygmented and self.frame_info.scope: 390 | assert_(not markers, ValueError("Cannot use pygmented with markers")) 391 | start_line, lines = self.frame_info._pygmented_scope_lines 392 | result = lines[self.lineno - start_line] 393 | if strip_leading_indent: 394 | result = result.replace(self.text[:self.leading_indent], "", 1) 395 | return result 396 | 397 | text = self.text 398 | 399 | # This just makes the loop below simpler 400 | markers = list(markers) + [MarkerInLine(position=len(text), is_start=False, string='')] 401 | 402 | markers.sort(key=lambda t: t[:2]) 403 | 404 | parts = [] 405 | if strip_leading_indent: 406 | start = self.leading_indent 407 | else: 408 | start = 0 409 | original_start = start 410 | 411 | for marker in markers: 412 | text_part = text[start:marker.position] 413 | if escape_html: 414 | text_part = html.escape(text_part) 415 | parts.append(text_part) 416 | parts.append(marker.string) 417 | 418 | # Ensure that start >= leading_indent 419 | start = max(marker.position, original_start) 420 | return ''.join(parts) 421 | 422 | 423 | def markers_from_ranges( 424 | ranges: Iterable[RangeInLine], 425 | converter: Callable[[RangeInLine], Optional[Tuple[str, str]]], 426 | ) -> List[MarkerInLine]: 427 | """ 428 | Helper to create MarkerInLines given some RangeInLines. 429 | converter should be a function accepting a RangeInLine returning 430 | either None (which is ignored) or a pair of strings which 431 | are used to create two markers included in the returned list. 432 | """ 433 | markers = [] 434 | for rang in ranges: 435 | converted = converter(rang) 436 | if converted is None: 437 | continue 438 | 439 | start_string, end_string = converted 440 | if not (isinstance(start_string, str) and isinstance(end_string, str)): 441 | raise TypeError("converter should return None or a pair of strings") 442 | 443 | markers += [ 444 | MarkerInLine(position=rang.start, is_start=True, string=start_string), 445 | MarkerInLine(position=rang.end, is_start=False, string=end_string), 446 | ] 447 | return markers 448 | 449 | 450 | def style_with_executing_node(style, modifier): 451 | from pygments.styles import get_style_by_name 452 | if isinstance(style, str): 453 | style = get_style_by_name(style) 454 | 455 | class NewStyle(style): 456 | for_executing_node = True 457 | 458 | styles = { 459 | **style.styles, 460 | **{ 461 | k.ExecutingNode: v + " " + modifier 462 | for k, v in style.styles.items() 463 | } 464 | } 465 | 466 | return NewStyle 467 | 468 | 469 | class RepeatedFrames: 470 | """ 471 | A sequence of consecutive stack frames which shouldn't be displayed because 472 | the same code and line number were repeated many times in the stack, e.g. 473 | because of deep recursion. 474 | 475 | Attributes: 476 | - frames: list of raw frame or traceback objects 477 | - frame_keys: list of tuples (frame.f_code, lineno) extracted from the frame objects. 478 | It's this information from the frames that is used to determine 479 | whether two frames should be considered similar (i.e. repeating). 480 | - description: A string briefly describing frame_keys 481 | """ 482 | def __init__( 483 | self, 484 | frames: List[Union[FrameType, TracebackType]], 485 | frame_keys: List[Tuple[CodeType, int]], 486 | ): 487 | self.frames = frames 488 | self.frame_keys = frame_keys 489 | 490 | @cached_property 491 | def description(self) -> str: 492 | """ 493 | A string briefly describing the repeated frames, e.g. 494 | my_function at line 10 (100 times) 495 | """ 496 | counts = sorted(Counter(self.frame_keys).items(), 497 | key=lambda item: (-item[1], item[0][0].co_name)) 498 | return ', '.join( 499 | '{name} at line {lineno} ({count} times)'.format( 500 | name=Source.for_filename(code.co_filename).code_qualname(code), 501 | lineno=lineno, 502 | count=count, 503 | ) 504 | for (code, lineno), count in counts 505 | ) 506 | 507 | def __repr__(self): 508 | return '<{self.__class__.__name__} {self.description}>'.format(self=self) 509 | 510 | 511 | class FrameInfo(object): 512 | """ 513 | Information about a frame! 514 | Pass either a frame object or a traceback object, 515 | and optionally an Options object to configure. 516 | 517 | Or use the classmethod FrameInfo.stack_data() for an iterator of FrameInfo and 518 | RepeatedFrames objects. 519 | 520 | Attributes: 521 | - frame: an actual stack frame object, either frame_or_tb or frame_or_tb.tb_frame 522 | - options 523 | - code: frame.f_code 524 | - source: a Source object 525 | - filename: a hopefully absolute file path derived from code.co_filename 526 | - scope: the AST node of the innermost function, class or module being executed 527 | - lines: a list of Line/LineGap objects to display, determined by options 528 | - executing: an Executing object from the `executing` library, which has: 529 | - .node: the AST node being executed in this frame, or None if it's unknown 530 | - .statements: a set of one or more candidate statements (AST nodes, probably just one) 531 | currently being executed in this frame. 532 | - .code_qualname(): the __qualname__ of the function or class being executed, 533 | or just the code name. 534 | 535 | Properties returning one or more pieces of source code (ranges of lines): 536 | - scope_pieces: all the pieces in the scope 537 | - included_pieces: a subset of scope_pieces determined by options 538 | - executing_piece: the piece currently being executed in this frame 539 | 540 | Properties returning lists of Variable objects: 541 | - variables: all variables in the scope 542 | - variables_by_lineno: variables organised into lines 543 | - variables_in_lines: variables contained within FrameInfo.lines 544 | - variables_in_executing_piece: variables contained within FrameInfo.executing_piece 545 | """ 546 | def __init__( 547 | self, 548 | frame_or_tb: Union[FrameType, TracebackType], 549 | options: Optional[Options] = None, 550 | ): 551 | self.executing = Source.executing(frame_or_tb) 552 | frame, self.lineno = frame_and_lineno(frame_or_tb) 553 | self.frame = frame 554 | self.code = frame.f_code 555 | self.options = options or Options() # type: Options 556 | self.source = self.executing.source # type: Source 557 | 558 | 559 | def __repr__(self): 560 | return "{self.__class__.__name__}({self.frame})".format(self=self) 561 | 562 | @classmethod 563 | def stack_data( 564 | cls, 565 | frame_or_tb: Union[FrameType, TracebackType], 566 | options: Optional[Options] = None, 567 | *, 568 | collapse_repeated_frames: bool = True 569 | ) -> Iterator[Union['FrameInfo', RepeatedFrames]]: 570 | """ 571 | An iterator of FrameInfo and RepeatedFrames objects representing 572 | a full traceback or stack. Similar consecutive frames are collapsed into RepeatedFrames 573 | objects, so always check what type of object has been yielded. 574 | 575 | Pass either a frame object or a traceback object, 576 | and optionally an Options object to configure. 577 | """ 578 | stack = list(iter_stack(frame_or_tb)) 579 | 580 | # Reverse the stack from a frame so that it's in the same order 581 | # as the order from a traceback, which is the order of a printed 582 | # traceback when read top to bottom (most recent call last) 583 | if is_frame(frame_or_tb): 584 | stack = stack[::-1] 585 | 586 | def mapper(f): 587 | return cls(f, options) 588 | 589 | if not collapse_repeated_frames: 590 | yield from map(mapper, stack) 591 | return 592 | 593 | def _frame_key(x): 594 | frame, lineno = frame_and_lineno(x) 595 | return frame.f_code, lineno 596 | 597 | yield from collapse_repeated( 598 | stack, 599 | mapper=mapper, 600 | collapser=RepeatedFrames, 601 | key=_frame_key, 602 | ) 603 | 604 | @cached_property 605 | def scope_pieces(self) -> List[range]: 606 | """ 607 | All the pieces (ranges of lines) contained in this object's .scope, 608 | unless there is no .scope (because the source isn't valid Python syntax) 609 | in which case it returns all the pieces in the source file, each containing one line. 610 | """ 611 | if not self.scope: 612 | return self.source.pieces 613 | 614 | scope_start, scope_end = self.source.line_range(self.scope) 615 | return [ 616 | piece 617 | for piece in self.source.pieces 618 | if scope_start <= piece.start and piece.stop <= scope_end 619 | ] 620 | 621 | @cached_property 622 | def filename(self) -> str: 623 | """ 624 | A hopefully absolute file path derived from .code.co_filename, 625 | the current working directory, and sys.path. 626 | Code based on ipython. 627 | """ 628 | result = self.code.co_filename 629 | 630 | if ( 631 | os.path.isabs(result) or 632 | ( 633 | result.startswith("<") and 634 | result.endswith(">") 635 | ) 636 | ): 637 | return result 638 | 639 | # Try to make the filename absolute by trying all 640 | # sys.path entries (which is also what linecache does) 641 | # as well as the current working directory 642 | for dirname in ["."] + list(sys.path): 643 | try: 644 | fullname = os.path.join(dirname, result) 645 | if os.path.isfile(fullname): 646 | return os.path.abspath(fullname) 647 | except Exception: 648 | # Just in case that sys.path contains very 649 | # strange entries... 650 | pass 651 | 652 | return result 653 | 654 | @cached_property 655 | def executing_piece(self) -> range: 656 | """ 657 | The piece (range of lines) containing the line currently being executed 658 | by the interpreter in this frame. 659 | """ 660 | return only( 661 | piece 662 | for piece in self.scope_pieces 663 | if self.lineno in piece 664 | ) 665 | 666 | @cached_property 667 | def included_pieces(self) -> List[range]: 668 | """ 669 | The list of pieces (ranges of lines) to display for this frame. 670 | Consists of .executing_piece, surrounding context pieces 671 | determined by .options.before and .options.after, 672 | and the function signature if a function is being executed and 673 | .options.include_signature is True (in which case this might not 674 | be a contiguous range of pieces). 675 | Always a subset of .scope_pieces. 676 | """ 677 | scope_pieces = self.scope_pieces 678 | if not self.scope_pieces: 679 | return [] 680 | 681 | pos = scope_pieces.index(self.executing_piece) 682 | pieces_start = max(0, pos - self.options.before) 683 | pieces_end = pos + 1 + self.options.after 684 | pieces = scope_pieces[pieces_start:pieces_end] 685 | 686 | if ( 687 | self.options.include_signature 688 | and not self.code.co_name.startswith('<') 689 | and isinstance(self.scope, (ast.FunctionDef, ast.AsyncFunctionDef)) 690 | and pieces_start > 0 691 | ): 692 | pieces.insert(0, scope_pieces[0]) 693 | 694 | return pieces 695 | 696 | @cached_property 697 | def _executing_node_common_indent(self) -> int: 698 | """ 699 | The common minimal indentation shared by the markers intended 700 | for an exception node that spans multiple lines. 701 | 702 | Intended to be used only internally. 703 | """ 704 | indents = [] 705 | lines = [line for line in self.lines if isinstance(line, Line)] 706 | 707 | for line in lines: 708 | for rang in line._raw_executing_node_ranges(): 709 | begin_text = len(line.text) - len(line.text.lstrip()) 710 | indent = max(rang.start, begin_text) 711 | indents.append(indent) 712 | 713 | if len(indents) <= 1: 714 | return 0 715 | 716 | return min(indents[1:]) 717 | 718 | @cached_property 719 | def lines(self) -> List[Union[Line, LineGap, BlankLineRange]]: 720 | """ 721 | A list of lines to display, determined by options. 722 | The objects yielded either have type Line, BlankLineRange 723 | or are the singleton LINE_GAP. 724 | Always check the type that you're dealing with when iterating. 725 | 726 | LINE_GAP can be created in two ways: 727 | - by truncating a piece of context that's too long, determined by 728 | .options.max_lines_per_piece 729 | - immediately after the signature piece if Options.include_signature is true 730 | and the following piece isn't already part of the included pieces. 731 | 732 | The Line objects are all within the ranges from .included_pieces. 733 | """ 734 | pieces = self.included_pieces 735 | if not pieces: 736 | return [] 737 | 738 | add_empty_lines = self.options.blank_lines in (BlankLines.VISIBLE, BlankLines.SINGLE) 739 | prev_piece = None 740 | result = [] 741 | for i, piece in enumerate(pieces): 742 | if ( 743 | i == 1 744 | and self.scope 745 | and pieces[0] == self.scope_pieces[0] 746 | and pieces[1] != self.scope_pieces[1] 747 | ): 748 | result.append(LINE_GAP) 749 | elif prev_piece and add_empty_lines and piece.start > prev_piece.stop: 750 | if self.options.blank_lines == BlankLines.SINGLE: 751 | result.append(BlankLineRange(prev_piece.stop, piece.start-1)) 752 | else: # BlankLines.VISIBLE 753 | for lineno in range(prev_piece.stop, piece.start): 754 | result.append(Line(self, lineno)) 755 | 756 | lines = [Line(self, i) for i in piece] # type: List[Line] 757 | if piece != self.executing_piece: 758 | lines = truncate( 759 | lines, 760 | max_length=self.options.max_lines_per_piece, 761 | middle=[LINE_GAP], 762 | ) 763 | result.extend(lines) 764 | prev_piece = piece 765 | 766 | real_lines = [ 767 | line 768 | for line in result 769 | if isinstance(line, Line) 770 | ] 771 | 772 | text = "\n".join( 773 | line.text 774 | for line in real_lines 775 | ) 776 | dedented_lines = dedent(text).splitlines() 777 | leading_indent = len(real_lines[0].text) - len(dedented_lines[0]) 778 | for line in real_lines: 779 | line.leading_indent = leading_indent 780 | return result 781 | 782 | @cached_property 783 | def scope(self) -> Optional[ast.AST]: 784 | """ 785 | The AST node of the innermost function, class or module being executed. 786 | """ 787 | if not self.source.tree or not self.executing.statements: 788 | return None 789 | 790 | stmt = list(self.executing.statements)[0] 791 | while True: 792 | # Get the parent first in case the original statement is already 793 | # a function definition, e.g. if we're calling a decorator 794 | # In that case we still want the surrounding scope, not that function 795 | stmt = stmt.parent 796 | if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)): 797 | return stmt 798 | 799 | @cached_property 800 | def _pygmented_scope_lines(self) -> Optional[Tuple[int, List[str]]]: 801 | # noinspection PyUnresolvedReferences 802 | from pygments.formatters import HtmlFormatter 803 | 804 | formatter = self.options.pygments_formatter 805 | scope = self.scope 806 | assert_(formatter, ValueError("Must set a pygments formatter in Options")) 807 | assert_(scope) 808 | 809 | if isinstance(formatter, HtmlFormatter): 810 | formatter.nowrap = True 811 | 812 | atext = self.source.asttext() 813 | node = self.executing.node 814 | if node and getattr(formatter.style, "for_executing_node", False): 815 | scope_start = atext.get_text_range(scope)[0] 816 | start, end = atext.get_text_range(node) 817 | start -= scope_start 818 | end -= scope_start 819 | ranges = [(start, end)] 820 | else: 821 | ranges = [] 822 | 823 | code = atext.get_text(scope) 824 | lines = _pygmented_with_ranges(formatter, code, ranges) 825 | 826 | start_line = self.source.line_range(scope)[0] 827 | 828 | return start_line, lines 829 | 830 | @cached_property 831 | def variables(self) -> List[Variable]: 832 | """ 833 | All Variable objects whose nodes are contained within .scope 834 | and whose values could be safely evaluated by pure_eval. 835 | """ 836 | if not self.scope: 837 | return [] 838 | 839 | evaluator = Evaluator.from_frame(self.frame) 840 | scope = self.scope 841 | node_values = [ 842 | pair 843 | for pair in evaluator.find_expressions(scope) 844 | if is_expression_interesting(*pair) 845 | ] # type: List[Tuple[ast.AST, Any]] 846 | 847 | if isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)): 848 | for node in ast.walk(scope.args): 849 | if not isinstance(node, ast.arg): 850 | continue 851 | name = node.arg 852 | try: 853 | value = evaluator.names[name] 854 | except KeyError: 855 | pass 856 | else: 857 | node_values.append((node, value)) 858 | 859 | # Group equivalent nodes together 860 | def get_text(n): 861 | if isinstance(n, ast.arg): 862 | return n.arg 863 | else: 864 | return self.source.asttext().get_text(n) 865 | 866 | def normalise_node(n): 867 | try: 868 | # Add parens to avoid syntax errors for multiline expressions 869 | return ast.parse('(' + get_text(n) + ')') 870 | except Exception: 871 | return n 872 | 873 | grouped = group_by_key_func( 874 | node_values, 875 | lambda nv: ast.dump(normalise_node(nv[0])), 876 | ) 877 | 878 | result = [] 879 | for group in grouped.values(): 880 | nodes, values = zip(*group) 881 | value = values[0] 882 | text = get_text(nodes[0]) 883 | if not text: 884 | continue 885 | result.append(Variable(text, nodes, value)) 886 | 887 | return result 888 | 889 | @cached_property 890 | def variables_by_lineno(self) -> Mapping[int, List[Tuple[Variable, ast.AST]]]: 891 | """ 892 | A mapping from 1-based line numbers to lists of pairs: 893 | - A Variable object 894 | - A specific AST node from the variable's .nodes list that's 895 | in the line at that line number. 896 | """ 897 | result = defaultdict(list) 898 | for var in self.variables: 899 | for node in var.nodes: 900 | for lineno in range(*self.source.line_range(node)): 901 | result[lineno].append((var, node)) 902 | return result 903 | 904 | @cached_property 905 | def variables_in_lines(self) -> List[Variable]: 906 | """ 907 | A list of Variable objects contained within the lines returned by .lines. 908 | """ 909 | return unique_in_order( 910 | var 911 | for line in self.lines 912 | if isinstance(line, Line) 913 | for var, node in self.variables_by_lineno[line.lineno] 914 | ) 915 | 916 | @cached_property 917 | def variables_in_executing_piece(self) -> List[Variable]: 918 | """ 919 | A list of Variable objects contained within the lines 920 | in the range returned by .executing_piece. 921 | """ 922 | return unique_in_order( 923 | var 924 | for lineno in self.executing_piece 925 | for var, node in self.variables_by_lineno[lineno] 926 | ) 927 | --------------------------------------------------------------------------------