diff --git a/.coveragerc b/.coveragerc index 2f29a3a..c391892 100644 --- a/.coveragerc +++ b/.coveragerc @@ -13,4 +13,6 @@ parallel = true [report] show_missing = true precision = 2 -omit = *migrations* +omit = + *migrations* + tests/bad*.py diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index 7ea058e..8dc5947 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -36,7 +36,7 @@ jobs: toxpython: 'python3.8' python_arch: 'x64' tox_env: 'py38' - os: 'macos-latest' + os: 'macos-13' - name: 'py39 (ubuntu)' python: '3.9' toxpython: 'python3.9' @@ -54,7 +54,7 @@ jobs: toxpython: 'python3.9' python_arch: 'x64' tox_env: 'py39' - os: 'macos-latest' + os: 'macos-13' - name: 'py310 (ubuntu)' python: '3.10' toxpython: 'python3.10' @@ -72,7 +72,7 @@ jobs: toxpython: 'python3.10' python_arch: 'x64' tox_env: 'py310' - os: 'macos-latest' + os: 'macos-13' - name: 'py311 (ubuntu)' python: '3.11' toxpython: 'python3.11' @@ -126,7 +126,7 @@ jobs: toxpython: 'pypy3.8' python_arch: 'x64' tox_env: 'pypy38' - os: 'macos-latest' + os: 'macos-13' - name: 'pypy39 (ubuntu)' python: 'pypy-3.9' toxpython: 'pypy3.9' @@ -144,7 +144,7 @@ jobs: toxpython: 'pypy3.9' python_arch: 'x64' tox_env: 'pypy39' - os: 'macos-latest' + os: 'macos-13' - name: 'pypy310 (ubuntu)' python: 'pypy-3.10' toxpython: 'pypy3.10' @@ -162,7 +162,7 @@ jobs: toxpython: 'pypy3.10' python_arch: 'x64' tox_env: 'pypy310' - os: 'macos-latest' + os: 'macos-13' steps: - uses: actions/checkout@v3 with: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e315ea4..06dce67 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -51,7 +51,7 @@ Changelog * Add support for PyPy3.5-5.7.1-beta. Previously ``AttributeError: 'Frame' object has no attribute 'clear'`` could be raised. See PyPy - issue `#2532 `_. + issue `#2532 `_. 1.3.1 (2017-03-27) ~~~~~~~~~~~~~~~~~~ diff --git a/README.rst b/README.rst index 49604e5..83f0287 100644 --- a/README.rst +++ b/README.rst @@ -146,7 +146,7 @@ Raising :: - >>> from six import reraise + >>> from tblib.decorators import reraise >>> reraise(*pickle.loads(s1)) Traceback (most recent call last): ... @@ -433,22 +433,26 @@ json.JSONDecoder:: {'tb_frame': {'f_code': {'co_filename': '', 'co_name': ''}, 'f_globals': {'__name__': '__main__'}, - 'f_lineno': 5}, + '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_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_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_lineno': 2, + 'f_locals': {}}, 'tb_lineno': 2, 'tb_next': None}}}} @@ -503,7 +507,7 @@ tblib.Traceback.from_string File "...examples.py", line 10, in func_c func_d() File "...examples.py", line 14, in func_d - raise Exception("Guessing time !") + raise Exception('Guessing time !') Exception: fail @@ -534,7 +538,7 @@ If you use the ``strict=False`` option then parsing is a bit more lax:: File "...examples.py", line 10, in func_c func_d() File "...examples.py", line 14, in func_d - raise Exception("Guessing time !") + raise Exception('Guessing time !') Exception: fail tblib.decorators.return_error @@ -607,6 +611,8 @@ Not very useful is it? Let's sort this out:: i.reraise() File "...tblib...decorators.py", line ..., in reraise reraise(self.exc_type, self.exc_value, self.traceback) + File "...tblib...decorators.py", line ..., in reraise + raise value.with_traceback(tb) File "...tblib...decorators.py", line ..., in return_exceptions_wrapper return func(*args, **kwargs) File "...tblib...decorators.py", line ..., in apply_with_return_error @@ -618,7 +624,7 @@ Not very useful is it? Let's sort this out:: File "...examples.py", line 10, in func_c func_d() File "...examples.py", line 14, in func_d - raise Exception("Guessing time !") + raise Exception('Guessing time !') Exception: Guessing time ! >>> pool.terminate() @@ -660,11 +666,13 @@ What if we have a local call stack ? local_0() File "", line 6, in local_0 i.reraise() - File "...tblib...decorators.py", line 20, in reraise + File "...tblib...decorators.py", line ..., in reraise reraise(self.exc_type, self.exc_value, self.traceback) - File "...tblib...decorators.py", line 27, in return_exceptions_wrapper + File "...tblib...decorators.py", line ..., in reraise + raise value.with_traceback(tb) + File "...tblib...decorators.py", line ..., in return_exceptions_wrapper return func(*args, **kwargs) - File "...tblib...decorators.py", line 47, in apply_with_return_error + File "...tblib...decorators.py", line ..., in apply_with_return_error return args[0](*args[1:]) File "...tests...examples.py", line 2, in func_a func_b() @@ -673,7 +681,7 @@ What if we have a local call stack ? File "...tests...examples.py", line 10, in func_c func_d() File "...tests...examples.py", line 14, in func_d - raise Exception("Guessing time !") + raise Exception('Guessing time !') Exception: Guessing time ! diff --git a/src/tblib/pickling_support.py b/src/tblib/pickling_support.py index 0085140..fcb5273 100644 --- a/src/tblib/pickling_support.py +++ b/src/tblib/pickling_support.py @@ -22,6 +22,19 @@ def pickle_traceback(tb, *, get_locals=None): ) +def unpickle_exception_with_new(func, args, cause, tb, context, suppress_context, notes): + inst = func.__new__(func) + if args is not None: + inst.args = args + inst.__cause__ = cause + inst.__traceback__ = tb + inst.__context__ = context + inst.__suppress_context__ = suppress_context + if notes is not None: + inst.__notes__ = notes + return inst + + # Note: Older versions of tblib will generate pickle archives that call unpickle_exception() with # fewer arguments. We assign default values to some of the arguments to support this. def unpickle_exception(func, args, cause, tb, context=None, suppress_context=False, notes=None): @@ -49,8 +62,18 @@ def pickle_exception(obj): assert isinstance(rv, tuple) assert len(rv) >= 2 + # Use __new__ whenever there is no customization by __reduce__ and + # __reduce_ex__. Note that OSError and descendants are known to require + # using a constructor, otherwise they do not set the errno, strerror and other + # attributes. + use_new = ( + obj.__class__.__reduce__ is BaseException.__reduce__ + and obj.__class__.__reduce_ex__ is BaseException.__reduce_ex__ + and not isinstance(obj, OSError) + ) + return ( - unpickle_exception, + unpickle_exception_with_new if use_new else unpickle_exception, rv[:2] + ( obj.__cause__, diff --git a/tests/test_issue65.py b/tests/test_issue65.py new file mode 100644 index 0000000..9da4a9f --- /dev/null +++ b/tests/test_issue65.py @@ -0,0 +1,27 @@ +import pickle + +from tblib import pickling_support + + +class HTTPrettyError(Exception): + pass + + +class UnmockedError(HTTPrettyError): + def __init__(self): + super().__init__('No mocking was registered, and real connections are not allowed (httpretty.allow_net_connect = False).') + + +def test_65(): + pickling_support.install() + + try: + raise UnmockedError + except Exception as e: + exc = e + + exc = pickle.loads(pickle.dumps(exc)) + + assert isinstance(exc, UnmockedError) + assert exc.args == ('No mocking was registered, and real connections are not allowed (httpretty.allow_net_connect = False).',) + assert exc.__traceback__ is not None diff --git a/tests/test_pickle_exception.py b/tests/test_pickle_exception.py index 53a9dce..db45254 100644 --- a/tests/test_pickle_exception.py +++ b/tests/test_pickle_exception.py @@ -164,3 +164,95 @@ def func(my_arg='2'): exc = pickle.loads(pickle.dumps(exc, protocol=protocol)) assert exc.__traceback__.tb_next.tb_frame.f_locals == {'my_variable': 1} + + +class CustomWithAttributesException(Exception): + def __init__(self, message, arg1, arg2, arg3): + super().__init__(message) + self.values12 = (arg1, arg2) + self.value3 = arg3 + + +def test_custom_with_attributes(): + try: + raise CustomWithAttributesException('bar', 1, 2, 3) + except Exception as e: + exc = e + + tblib.pickling_support.install(exc) + exc = pickle.loads(pickle.dumps(exc)) + + assert isinstance(exc, CustomWithAttributesException) + assert exc.args == ('bar',) + assert exc.values12 == (1, 2) + assert exc.value3 == 3 + assert exc.__traceback__ is not None + + +class CustomReduceException(Exception): + def __init__(self, message, arg1, arg2, arg3): + super().__init__(message) + self.values12 = (arg1, arg2) + self.value3 = arg3 + + def __reduce__(self): + return self.__class__, self.args + self.values12 + (self.value3,) + + +def test_custom_reduce(): + try: + raise CustomReduceException('foo', 1, 2, 3) + except Exception as e: + exc = e + + tblib.pickling_support.install(exc) + exc = pickle.loads(pickle.dumps(exc)) + + assert isinstance(exc, CustomReduceException) + assert exc.args == ('foo',) + assert exc.values12 == (1, 2) + assert exc.value3 == 3 + assert exc.__traceback__ is not None + + +class CustomReduceExException(Exception): + def __init__(self, message, arg1, arg2, protocol): + super().__init__(message) + self.values12 = (arg1, arg2) + self.value3 = protocol + + def __reduce_ex__(self, protocol): + return self.__class__, self.args + self.values12 + (self.value3,) + + +@pytest.mark.parametrize('protocol', [None, *list(range(1, pickle.HIGHEST_PROTOCOL + 1))]) +def test_custom_reduce_ex(protocol): + try: + raise CustomReduceExException('foo', 1, 2, 3) + except Exception as e: + exc = e + + tblib.pickling_support.install(exc) + exc = pickle.loads(pickle.dumps(exc, protocol=protocol)) + + assert isinstance(exc, CustomReduceExException) + assert exc.args == ('foo',) + assert exc.values12 == (1, 2) + assert exc.value3 == 3 + assert exc.__traceback__ is not None + + +def test_oserror(): + try: + raise OSError(13, 'Permission denied') + except Exception as e: + exc = e + + tblib.pickling_support.install(exc) + exc = pickle.loads(pickle.dumps(exc)) + + assert isinstance(exc, OSError) + assert exc.args == (13, 'Permission denied') + assert exc.errno == 13 + assert exc.strerror == 'Permission denied' + assert exc.__traceback__ is not None diff --git a/tox.ini b/tox.ini index 10215f8..62daa64 100644 --- a/tox.ini +++ b/tox.ini @@ -40,7 +40,7 @@ deps = pytest pytest-cov commands = - {posargs:pytest --cov --cov-report=term-missing --cov-report=xml -vv tests} + {posargs:pytest --cov --cov-report=term-missing --cov-report=xml -vv tests README.rst} [testenv:check] deps =