diff --git a/README.rst b/README.rst index 4395222..686521b 100644 --- a/README.rst +++ b/README.rst @@ -416,39 +416,14 @@ convert a Traceback into and from a dictionary serializable by the stdlib json.JSONDecoder:: >>> import json - >>> from pprint import pprint >>> try: ... inner_2() ... except: ... et, ev, tb = sys.exc_info() ... tb = Traceback(tb) ... tb_dict = tb.to_dict() - ... pprint(tb_dict) - {'tb_frame': {'f_code': {'co_filename': '', - 'co_name': ''}, - 'f_globals': {'__name__': '__main__'}, - 'f_lineno': 5, - 'f_locals': {}}, - 'tb_lineno': 2, - 'tb_next': {'tb_frame': {'f_code': {'co_filename': ..., - 'co_name': 'inner_2'}, - 'f_globals': {'__name__': '__main__'}, - 'f_lineno': 2, - 'f_locals': {}}, - 'tb_lineno': 2, - 'tb_next': {'tb_frame': {'f_code': {'co_filename': ..., - 'co_name': 'inner_1'}, - 'f_globals': {'__name__': '__main__'}, - 'f_lineno': 2, - 'f_locals': {}}, - 'tb_lineno': 2, - 'tb_next': {'tb_frame': {'f_code': {'co_filename': ..., - 'co_name': 'inner_0'}, - 'f_globals': {'__name__': '__main__'}, - 'f_lineno': 2, - 'f_locals': {}}, - 'tb_lineno': 2, - 'tb_next': None}}}} + ... 'tb_frame' in tb_dict and 'tb_lineno' in tb_dict + True tblib.Traceback.from_dict ````````````````````````` diff --git a/src/tblib/__init__.py b/src/tblib/__init__.py index 546c327..79f2372 100644 --- a/src/tblib/__init__.py +++ b/src/tblib/__init__.py @@ -1,3 +1,4 @@ +import itertools import re import sys @@ -6,6 +7,120 @@ FRAME_RE = re.compile(r'^\s*File "(?P.+)", line (?P\d+)(, in (?P.+))?$') +# PyPy uses a different linetable format than CPython, so we can't use custom +# linetables for column position info on PyPy +_is_pypy = sys.implementation.name == 'pypy' + + +def _get_code_position(code, instruction_index): + """ + Get position information (line, end_line, col, end_col) for a bytecode instruction. + Returns (None, None, None, None) if position info is not available. + """ + if not hasattr(code, 'co_positions'): + return (None, None, None, None) + if instruction_index < 0: + return (None, None, None, None) + positions_gen = code.co_positions() + return next(itertools.islice(positions_gen, instruction_index // 2, None), (None, None, None, None)) + + +def _make_linetable_with_positions(colno, end_colno): + """ + Create a co_linetable bytes object for the 'raise __traceback_maker' stub + with the specified column positions. + + The stub code has 3 instructions: + - Instruction 0: RESUME (no location) + - Instruction 1: LOAD_NAME __traceback_maker + - Instruction 2: RAISE_VARARGS (this is where the exception occurs) + + We need to set the column positions for instruction 2 (the RAISE instruction). + """ + # The linetable format for Python 3.11+: + # Each entry starts with a byte: (code << 3) | (instruction_count - 1) + # Code 10 (0xa) = ONE_LINE0: same line, followed by col and end_col bytes + # Code 11 (0xb) = ONE_LINE1: line+1, followed by col and end_col bytes + # Code 14 (0xe) = LONG: complex variable-length format + # Code 15 (0xf) = NONE: no location info + + if colno is None: + colno = 0 + if end_colno is None: + end_colno = 0 + + # Clamp values to valid range (0-255 for simple encoding) + colno = max(0, min(255, colno)) + end_colno = max(0, min(255, end_colno)) + + # Build the linetable: + # Entry 0: LONG format for instruction 0 (RESUME at line 0->1) + # 0xf0 = (14 << 3) | 0 = LONG, 1 instruction + # followed by: line_delta=1 (as signed varint), end_line_delta=0, col+1=1, end_col+1=1 + # Entry 1: ONE_LINE1 for instruction 1 (LOAD_NAME, same line) + # 0xd8 = (11 << 3) | 0 = ONE_LINE1, 1 instruction + # We keep original columns for LOAD_NAME (not critical) + # Entry 2: ONE_LINE0 for instruction 2 (RAISE_VARARGS) + # 0xd0 = (10 << 3) | 0 = ONE_LINE0, 1 instruction + # followed by: col, end_col + + linetable = bytes( + [ + 0xF0, + 0x03, + 0x01, + 0x01, + 0x01, # Entry 0: LONG format (original header) + 0xD8, + 0x06, + 0x17, # Entry 1: ONE_LINE1, col=6, end_col=23 (for LOAD_NAME) + 0xD0, + colno, + end_colno, # Entry 2: ONE_LINE0, col=colno, end_col=end_colno + ] + ) + return linetable + + +def _make_pypy_linetable_with_positions(colno, end_colno, lineno): + """ + Create a co_linetable for PyPy with the specified column positions. + + PyPy uses a different linetable format than CPython: + - Each instruction gets a variable-length entry + - Format: varint(lineno_delta) + optional(col_offset+1, end_col_offset+1, end_line_delta) + - lineno_delta is relative to co_firstlineno, encoded as (lineno - firstlineno + 1) + - Column offsets are stored +1 to distinguish from "no info" (single 0 byte) + + The stub 'raise __traceback_maker' has 2 instructions on PyPy: + - Instruction 0: LOAD_NAME __traceback_maker (col 6-23) + - Instruction 1: RAISE_VARARGS (col from colno-end_colno) + """ + if colno is None: + colno = 0 + if end_colno is None: + end_colno = 0 + + # Clamp to valid range (0-254 since we store +1) + colno = max(0, min(254, colno)) + end_colno = max(0, min(254, end_colno)) + + firstlineno = lineno + lineno_delta = lineno - firstlineno + 1 + end_line_delta = 0 + + # Encode as varint (lineno_delta=1 fits in 1 byte: 0x01) + # For small values (<128), varint is just the value itself + lineno_varint = bytes([lineno_delta]) + + # Entry for instruction 0 (LOAD_NAME): original position (col 6-23) + entry0 = lineno_varint + bytes([7, 24, 0]) # col_offset=6 (+1=7), end_col_offset=23 (+1=24), end_line_delta=0 + + # Entry for instruction 1 (RAISE_VARARGS): our custom position + entry1 = lineno_varint + bytes([colno + 1, end_colno + 1, end_line_delta]) + + return entry0 + entry1 + class _AttrDict(dict): __slots__ = () @@ -22,6 +137,10 @@ class __traceback_maker(Exception): pass +# Alias without leading underscores to avoid name mangling when used inside class methods +_tb_maker = __traceback_maker + + class TracebackParseError(Exception): pass @@ -97,6 +216,26 @@ def __init__(self, tb, *, get_locals=None): self.tb_frame = Frame(tb.tb_frame, get_locals=get_locals) self.tb_lineno = int(tb.tb_lineno) + # Capture column position information if available (Python 3.11+ on CPython) + # This is used to reconstruct the caret position in tracebacks + if hasattr(tb, 'tb_colno') and tb.tb_colno is not None: + # Input already has column info (e.g., from from_dict) + self.tb_colno = tb.tb_colno + self.tb_end_colno = getattr(tb, 'tb_end_colno', None) + self.tb_end_lineno = getattr(tb, 'tb_end_lineno', None) + else: + # Try to extract from the code object + tb_lasti = getattr(tb, 'tb_lasti', -1) + if tb_lasti >= 0 and hasattr(tb, 'tb_frame') and hasattr(tb.tb_frame, 'f_code'): + _, end_lineno, colno, end_colno = _get_code_position(tb.tb_frame.f_code, tb_lasti) + self.tb_end_lineno = end_lineno + self.tb_colno = colno + self.tb_end_colno = end_colno + else: + self.tb_end_lineno = None + self.tb_colno = None + self.tb_end_colno = None + # Build in place to avoid exceeding the recursion limit tb = tb.tb_next prev_traceback = self @@ -105,6 +244,24 @@ def __init__(self, tb, *, get_locals=None): traceback = object.__new__(cls) traceback.tb_frame = Frame(tb.tb_frame, get_locals=get_locals) traceback.tb_lineno = int(tb.tb_lineno) + + # Capture column position information for each frame + if hasattr(tb, 'tb_colno') and tb.tb_colno is not None: + traceback.tb_colno = tb.tb_colno + traceback.tb_end_colno = getattr(tb, 'tb_end_colno', None) + traceback.tb_end_lineno = getattr(tb, 'tb_end_lineno', None) + else: + tb_lasti = getattr(tb, 'tb_lasti', -1) + if tb_lasti >= 0 and hasattr(tb, 'tb_frame') and hasattr(tb.tb_frame, 'f_code'): + _, end_lineno, colno, end_colno = _get_code_position(tb.tb_frame.f_code, tb_lasti) + traceback.tb_end_lineno = end_lineno + traceback.tb_colno = colno + traceback.tb_end_colno = end_colno + else: + traceback.tb_end_lineno = None + traceback.tb_colno = None + traceback.tb_end_colno = None + prev_traceback.tb_next = traceback prev_traceback = traceback tb = tb.tb_next @@ -123,18 +280,39 @@ def as_traceback(self): ) while current: f_code = current.tb_frame.f_code - code = stub.replace( - co_firstlineno=current.tb_lineno, - co_argcount=0, - co_filename=f_code.co_filename, - co_name=f_code.co_name, - co_freevars=(), - co_cellvars=(), - ) + + # Build replace kwargs - include linetable with column positions if available + replace_kwargs = { + 'co_firstlineno': current.tb_lineno, + 'co_argcount': 0, + 'co_filename': f_code.co_filename, + 'co_name': f_code.co_name, + 'co_freevars': (), + 'co_cellvars': (), + } + + # If we have column position info, create a custom linetable + # Both CPython 3.10+ and PyPy 3.10+ support co_linetable (but use different formats) + if hasattr(stub, 'co_linetable'): + colno = getattr(current, 'tb_colno', None) + end_colno = getattr(current, 'tb_end_colno', None) + if colno is not None or end_colno is not None: + if _is_pypy: + replace_kwargs['co_linetable'] = _make_pypy_linetable_with_positions(colno, end_colno, current.tb_lineno) + else: + replace_kwargs['co_linetable'] = _make_linetable_with_positions(colno, end_colno) + + code = stub.replace(**replace_kwargs) # noinspection PyBroadException try: - exec(code, dict(current.tb_frame.f_globals), dict(current.tb_frame.f_locals)) # noqa: S102 + # Must include __traceback_maker in globals so the LOAD_NAME succeeds + # and the exception is raised by RAISE_VARARGS (at tb_lasti=4), not by + # NameError from LOAD_NAME (at tb_lasti=2). This is important for + # correct column position information. + globals_dict = dict(current.tb_frame.f_globals) + globals_dict['__traceback_maker'] = _tb_maker + exec(code, globals_dict, dict(current.tb_frame.f_locals)) # noqa: S102 except Exception: next_tb = sys.exc_info()[2].tb_next if top_tb is None: @@ -173,11 +351,19 @@ def as_dict(self): 'f_code': code, 'f_lineno': self.tb_frame.f_lineno, } - return { + result = { 'tb_frame': frame, 'tb_lineno': self.tb_lineno, 'tb_next': tb_next, } + # Include column position info if available (Python 3.11+) + if getattr(self, 'tb_colno', None) is not None: + result['tb_colno'] = self.tb_colno + if getattr(self, 'tb_end_colno', None) is not None: + result['tb_end_colno'] = self.tb_end_colno + if getattr(self, 'tb_end_lineno', None) is not None: + result['tb_end_lineno'] = self.tb_end_lineno + return result to_dict = as_dict @@ -205,8 +391,17 @@ def from_dict(cls, dct): tb_frame=frame, tb_lineno=dct['tb_lineno'], tb_next=tb_next, + # Include column position info if present in the dict + tb_colno=dct.get('tb_colno'), + tb_end_colno=dct.get('tb_end_colno'), + tb_end_lineno=dct.get('tb_end_lineno'), ) - return cls(tb, get_locals=get_all_locals) + instance = cls(tb, get_locals=get_all_locals) + # Restore column position info from dict + instance.tb_colno = dct.get('tb_colno') + instance.tb_end_colno = dct.get('tb_end_colno') + instance.tb_end_lineno = dct.get('tb_end_lineno') + return instance @classmethod def from_string(cls, string, strict=True): diff --git a/src/tblib/pickling_support.py b/src/tblib/pickling_support.py index 2f57449..8fd96fb 100644 --- a/src/tblib/pickling_support.py +++ b/src/tblib/pickling_support.py @@ -3,26 +3,34 @@ from functools import partial from types import TracebackType -from . import Frame from . import Traceback if sys.version_info < (3, 11): ExceptionGroup = None -def unpickle_traceback(tb_frame, tb_lineno, tb_next): +def unpickle_traceback(tb_frame, tb_lineno, tb_next, tb_colno=None, tb_end_colno=None, tb_end_lineno=None): ret = object.__new__(Traceback) ret.tb_frame = tb_frame ret.tb_lineno = tb_lineno ret.tb_next = tb_next + # Restore column position info (Python 3.11+) + ret.tb_colno = tb_colno + ret.tb_end_colno = tb_end_colno + ret.tb_end_lineno = tb_end_lineno return ret.as_traceback() def pickle_traceback(tb, *, get_locals=None): + # Wrap with Traceback to capture column position info + tb_wrapper = Traceback(tb, get_locals=get_locals) return unpickle_traceback, ( - Frame(tb.tb_frame, get_locals=get_locals), - tb.tb_lineno, - tb.tb_next and Traceback(tb.tb_next, get_locals=get_locals), + tb_wrapper.tb_frame, + tb_wrapper.tb_lineno, + tb_wrapper.tb_next, + getattr(tb_wrapper, 'tb_colno', None), + getattr(tb_wrapper, 'tb_end_colno', None), + getattr(tb_wrapper, 'tb_end_lineno', None), ) diff --git a/tests/test_pickle_exception.py b/tests/test_pickle_exception.py index 0990d9d..fe3b1d3 100644 --- a/tests/test_pickle_exception.py +++ b/tests/test_pickle_exception.py @@ -1,4 +1,6 @@ import os +import pickle +import sys from traceback import format_exception try: @@ -7,9 +9,6 @@ # Python 2 import copy_reg as copyreg -import pickle -import sys - import pytest import tblib.pickling_support @@ -30,10 +29,6 @@ class CustomError(Exception): pass -def strip_locations(tb_text): - return tb_text.replace(' ~~^~~\n', '').replace(' ^^^^^^^^^^^^^^^^^\n', '') - - @pytest.mark.parametrize('protocol', [None, *list(range(1, pickle.HIGHEST_PROTOCOL + 1))]) @pytest.mark.parametrize('how', ['global', 'instance', 'class']) def test_install(clear_dispatch_table, how, protocol): @@ -63,7 +58,7 @@ def test_install(clear_dispatch_table, how, protocol): else: raise AssertionError - expected_format_exception = strip_locations(''.join(format_exception(type(exc), exc, exc.__traceback__))) + expected_format_exception = ''.join(format_exception(type(exc), exc, exc.__traceback__)) # Populate Exception.__dict__, which is used in some cases exc.x = 1 @@ -93,7 +88,8 @@ def test_install(clear_dispatch_table, how, protocol): if has_python311: assert exc.__notes__ == ['note 1', 'note 2'] - assert expected_format_exception == strip_locations(''.join(format_exception(type(exc), exc, exc.__traceback__))) + actual_format_exception = ''.join(format_exception(type(exc), exc, exc.__traceback__)) + assert expected_format_exception == actual_format_exception @tblib.pickling_support.install diff --git a/tests/test_tblib.py b/tests/test_tblib.py index 6d01ebe..c892989 100644 --- a/tests/test_tblib.py +++ b/tests/test_tblib.py @@ -1,13 +1,25 @@ +import io import pickle +import sys import traceback +import types + +import pytest from tblib import Traceback +from tblib import TracebackParseError +from tblib import _get_code_position +from tblib import _make_linetable_with_positions +from tblib import _make_pypy_linetable_with_positions from tblib import pickling_support pickling_support.install() pytest_plugins = ('pytester',) +# Column position info requires Python 3.11+ +has_column_positions = hasattr(types.CodeType, 'co_positions') + def test_get_locals(): def get_locals(frame): @@ -34,25 +46,32 @@ def func(my_arg='2'): value = Traceback(exc.__traceback__, get_locals=get_locals).as_dict() lineno = exc.__traceback__.tb_lineno - assert value == { - 'tb_frame': { - 'f_globals': {'__name__': 'test_tblib', '__file__': __file__}, - 'f_locals': {}, - 'f_code': {'co_filename': __file__, 'co_name': 'test_get_locals'}, - 'f_lineno': lineno + 10, - }, - 'tb_lineno': lineno, - 'tb_next': { - 'tb_frame': { - 'f_globals': {'__name__': 'test_tblib', '__file__': __file__}, - 'f_locals': {'my_variable': 1}, - 'f_code': {'co_filename': __file__, 'co_name': 'func'}, - 'f_lineno': lineno - 3, - }, - 'tb_lineno': lineno - 3, - 'tb_next': None, - }, + + # Check structure (excluding column fields which depend on source formatting) + assert value['tb_frame'] == { + 'f_globals': {'__name__': 'test_tblib', '__file__': __file__}, + 'f_locals': {}, + 'f_code': {'co_filename': __file__, 'co_name': 'test_get_locals'}, + 'f_lineno': lineno + 10, + } + assert value['tb_lineno'] == lineno + assert value['tb_next']['tb_frame'] == { + 'f_globals': {'__name__': 'test_tblib', '__file__': __file__}, + 'f_locals': {'my_variable': 1}, + 'f_code': {'co_filename': __file__, 'co_name': 'func'}, + 'f_lineno': lineno - 3, } + assert value['tb_next']['tb_lineno'] == lineno - 3 + assert value['tb_next']['tb_next'] is None + + # On Python 3.11+, column position info should be present + if has_column_positions: + assert 'tb_colno' in value + assert 'tb_end_colno' in value + assert isinstance(value['tb_colno'], int) + assert isinstance(value['tb_end_colno'], int) + assert 'tb_colno' in value['tb_next'] + assert 'tb_end_colno' in value['tb_next'] assert Traceback.from_dict(value).tb_next.tb_frame.f_locals == {'my_variable': 1} @@ -78,6 +97,7 @@ def test_parse_traceback(): ] tb2 = Traceback(pytb) + # Expected structure without column fields (parsed from string has no column info) expected_dict = { 'tb_frame': { 'f_code': {'co_filename': 'file1', 'co_name': ''}, @@ -108,7 +128,33 @@ def test_parse_traceback(): } tb3 = Traceback.from_dict(expected_dict) tb4 = pickle.loads(pickle.dumps(tb3)) - assert tb4.as_dict() == tb3.as_dict() == tb2.as_dict() == tb1.as_dict() == expected_dict + + # tb1 (from string) has no column info + assert tb1.as_dict() == expected_dict + + # tb3, tb4 (from dict without column info) have no column info + assert tb3.as_dict() == expected_dict + assert tb4.as_dict() == expected_dict + + # tb2 (wrapped from reconstructed traceback) has column info on Python 3.11+ + # because it extracts positions from the stub code object + tb2_dict = tb2.as_dict() + if has_column_positions: + # Column info should be present + assert 'tb_colno' in tb2_dict + + # Remove column fields to compare structure + def without_columns(d): + if d is None: + return None + result = {k: v for k, v in d.items() if k not in ('tb_colno', 'tb_end_colno', 'tb_end_lineno')} + if 'tb_next' in result: + result['tb_next'] = without_columns(result['tb_next']) + return result + + assert without_columns(tb2_dict) == expected_dict + else: + assert tb2_dict == expected_dict def test_large_line_number(): @@ -212,3 +258,228 @@ def test_raise(): 'RuntimeError', ] ) + + +@pytest.mark.skipif(not has_column_positions, reason='Column positions require Python 3.11+') +def test_caret_position_preserved(): + """Test that caret positions are preserved through reconstruction.""" + + def inner(): + x = {'a': 1} + return x['b'] # KeyError here - caret should point to x['b'] + + try: + inner() + except KeyError: + original_tb = sys.exc_info()[2] + + tb_wrapper = Traceback(original_tb) + + inner_frame = tb_wrapper.tb_next + assert inner_frame.tb_colno is not None, 'tb_colno should be captured' + assert inner_frame.tb_end_colno is not None, 'tb_end_colno should be captured' + + reconstructed_tb = tb_wrapper.as_traceback() + + original_output = io.StringIO() + traceback.print_exception(KeyError, KeyError('b'), original_tb, file=original_output) + + reconstructed_output = io.StringIO() + traceback.print_exception(KeyError, KeyError('b'), reconstructed_tb, file=reconstructed_output) + + original_lines = original_output.getvalue().splitlines() + reconstructed_lines = reconstructed_output.getvalue().splitlines() + + assert len(original_lines) == len(reconstructed_lines), ( + f'Different number of lines: {len(original_lines)} vs {len(reconstructed_lines)}' + ) + + for i, (orig, recon) in enumerate(zip(original_lines, reconstructed_lines)): + assert orig == recon, f'Line {i} differs:\n Original: {orig!r}\n Reconstructed: {recon!r}' + + +@pytest.mark.skipif(not has_column_positions, reason='Column positions require Python 3.11+') +def test_caret_position_in_dict(): + """Test that caret positions are preserved in dictionary serialization.""" + + def inner(): + x = {'a': 1} + return x['b'] + + try: + inner() + except KeyError: + original_tb = sys.exc_info()[2] + + tb_wrapper = Traceback(original_tb) + tb_dict = tb_wrapper.as_dict() + + inner_dict = tb_dict['tb_next'] + assert 'tb_colno' in inner_dict, 'tb_colno should be in dict' + assert 'tb_end_colno' in inner_dict, 'tb_end_colno should be in dict' + assert inner_dict['tb_colno'] is not None + assert inner_dict['tb_end_colno'] is not None + + tb_from_dict = Traceback.from_dict(tb_dict) + assert tb_from_dict.tb_next.tb_colno == inner_dict['tb_colno'] + assert tb_from_dict.tb_next.tb_end_colno == inner_dict['tb_end_colno'] + + +@pytest.mark.skipif(not has_column_positions, reason='Column positions require Python 3.11+') +def test_caret_position_pickle(): + """Test that caret positions are preserved through pickling.""" + + def inner(): + x = {'a': 1} + return x['b'] + + try: + inner() + except KeyError: + original_tb = sys.exc_info()[2] + + tb_wrapper = Traceback(original_tb) + original_colno = tb_wrapper.tb_next.tb_colno + original_end_colno = tb_wrapper.tb_next.tb_end_colno + + # Pickle and unpickle + pickled = pickle.dumps(tb_wrapper) + unpickled = pickle.loads(pickled) + + assert unpickled.tb_next.tb_colno == original_colno + assert unpickled.tb_next.tb_end_colno == original_end_colno + + +def test_caret_position_without_column_info(): + """Test that reconstruction works when column info is not available.""" + tb = Traceback.from_string( + """ +Traceback (most recent call last): + File "test.py", line 10, in + foo() +ValueError: test +""" + ) + + assert tb.tb_colno is None + assert tb.tb_end_colno is None + + reconstructed = tb.as_traceback() + assert reconstructed is not None + assert reconstructed.tb_lineno == 10 + + +def test_caret_position_chained_exceptions(): + """Test caret positions with chained exceptions.""" + + def outer(): + inner() + + def inner(): + x = [1, 2, 3] + return x[10] # IndexError here + + try: + outer() + except IndexError: + original_tb = sys.exc_info()[2] + + tb_wrapper = Traceback(original_tb) + reconstructed_tb = tb_wrapper.as_traceback() + + # Walk both chains and compare + orig = original_tb + recon = reconstructed_tb + while orig is not None: + assert recon is not None, 'Reconstructed chain is shorter' + assert orig.tb_lineno == recon.tb_lineno + orig = orig.tb_next + recon = recon.tb_next + assert recon is None, 'Reconstructed chain is longer' + + +def test_traceback_from_string_invalid(): + """Test TracebackParseError is raised for invalid input.""" + with pytest.raises(TracebackParseError, match='Could not find any frames'): + Traceback.from_string('Not a valid traceback') + + +@pytest.mark.skipif(not has_column_positions, reason='Column positions require Python 3.11+') +def test_get_code_position_edge_cases(): + """Test _get_code_position edge cases for coverage.""" + class FakeCode: + pass + + result = _get_code_position(FakeCode(), 0) + assert result == (None, None, None, None) + + code = compile('x = 1', '', 'exec') + result = _get_code_position(code, -1) + assert result == (None, None, None, None) + + +@pytest.mark.skipif(not has_column_positions, reason='Column positions require Python 3.11+') +def test_linetable_functions_with_none(): + """Test linetable creation functions handle None values correctly.""" + if sys.implementation.name == 'pypy': + result = _make_pypy_linetable_with_positions(None, 10, 5) + assert isinstance(result, bytes) + + result = _make_pypy_linetable_with_positions(5, None, 5) + assert isinstance(result, bytes) + else: + result = _make_linetable_with_positions(None, 10) + assert isinstance(result, bytes) + + result = _make_linetable_with_positions(5, None) + assert isinstance(result, bytes) + + +@pytest.mark.skipif(not has_column_positions, reason='Column positions require Python 3.11+') +def test_traceback_maker_in_globals(): + """ + Test that __traceback_maker is properly defined in globals during reconstruction. + + Without __traceback_maker in globals, LOAD_NAME would raise NameError at + tb_lasti=2 (column 6-23), instead of RAISE_VARARGS executing at tb_lasti=4 + (column 0-23). This would capture column positions from the wrong instruction, + showing only '__traceback_maker' instead of the full 'raise __traceback_maker' + expression. + """ + + def cause_error(): + result = 1 / 0 # Division at specific columns + return result + + try: + cause_error() + except ZeroDivisionError: + original_tb = sys.exc_info()[2] + + # Get original column info + tb_wrapper = Traceback(original_tb) + original_colno = tb_wrapper.tb_next.tb_colno + original_end_colno = tb_wrapper.tb_next.tb_end_colno + + # These should be non-None if captured correctly + assert original_colno is not None, 'Should capture column start' + assert original_end_colno is not None, 'Should capture column end' + + # Reconstruct traceback + reconstructed_tb = tb_wrapper.as_traceback() + assert reconstructed_tb.tb_next is not None + + # Verify the code object has the correct column positions + code = reconstructed_tb.tb_next.tb_frame.f_code + if hasattr(code, 'co_positions'): + # Get positions for the RAISE_VARARGS instruction (should be last) + positions = list(code.co_positions()) + last_pos = positions[-1] + + # The last position should match our captured columns + assert last_pos[2] == original_colno, f'Column start mismatch: {last_pos[2]} != {original_colno}' + assert last_pos[3] == original_end_colno, f'Column end mismatch: {last_pos[3]} != {original_end_colno}' + + # Critically: column start should be 0 (full expression), not 6 (just '__traceback_maker') + # If __traceback_maker wasn't in globals, we'd get column 6 from LOAD_NAME instruction + assert last_pos[2] != 6, 'Column positions from wrong instruction (LOAD_NAME instead of RAISE_VARARGS)' diff --git a/tox.ini b/tox.ini index 542cbf8..ea58ff3 100644 --- a/tox.ini +++ b/tox.ini @@ -22,7 +22,6 @@ ignore_basepython_conflict = true setenv = PYTHONPATH={toxinidir}/tests PYTHONUNBUFFERED=yes - PYTHONNODEBUGRANGES=yes passenv = * package = wheel