From e2523b347e52707f87d7078daad1a93940c12e2e Mon Sep 17 00:00:00 2001 From: lubieowoce Date: Fri, 31 Aug 2018 22:35:25 +0200 Subject: [PATCH 1/3] Fixed infinite recursion when accessing `__orig_class__` on a decorated class with `__getattr[ibute]__` --- pytypes/type_util.py | 21 +++++++++++++++++++-- pytypes/typechecker.py | 4 ++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/pytypes/type_util.py b/pytypes/type_util.py index 1eae84e..6db8585 100644 --- a/pytypes/type_util.py +++ b/pytypes/type_util.py @@ -420,7 +420,7 @@ def _deep_type(obj, checked, checked_len, depth = None, max_sample = None, get_t if get_type is None: get_type = type try: - res = obj.__orig_class__ + res = _get_orig_class(obj) except AttributeError: res = get_type(obj) if depth == 0 or util._is_in(obj, checked[:checked_len]): @@ -2249,7 +2249,7 @@ def _check_caller_type(return_type, cllable = None, call_args = None, clss = Non call_args = util.get_current_args(caller_level+1, cllable, util.getargnames(specs)) if slf: try: - orig_clss = call_args[0].__orig_class__ + orig_clss = _get_orig_class(call_args[0]) except AttributeError: orig_clss = call_args[0].__class__ call_args = call_args[1:] @@ -2476,3 +2476,20 @@ def __call__(self, frame, event, arg): else: if self._previous_profiler is not None: self._previous_profiler(frame, event, arg) + + +def _get_orig_class(obj): + """Returns `obj.__orig_class__` protecting from infinite recursion in `__getattr[ibute]__` wrapped in a `checker_tp`. + (See `checker_tp` in `typechecker._typeinspect_func for context) + Necessary if: + - we're wrapping a method (`obj` is `self`/`cls`) and either + - the object's class defines __getattribute__ + or + - the object doesn't have an `__orig_class__` attribute + and the object's class defines __getattr__. + In such a situation, `parent_class = args_kw[0].__orig_class__` + would call `__getattr[ibute]__`. But that method is wrapped in a `checker_tp` too, + so then we'd go into the wrapped `__getattr[ibute]__` and do + `parent_class = args_kw[0].__orig_class__`, which would call `__getattr[ibute]__` again, and so on. + So to bypass `__getattr[ibute]__` we do this: """ + return object.__getattribute__(obj, '__orig_class__') \ No newline at end of file diff --git a/pytypes/typechecker.py b/pytypes/typechecker.py index fbac409..50a1234 100644 --- a/pytypes/typechecker.py +++ b/pytypes/typechecker.py @@ -33,7 +33,7 @@ from .type_util import type_str, has_type_hints, _has_type_hints, is_builtin_type, \ deep_type, _funcsigtypes, _issubclass, _isinstance, _find_typed_base_method, \ _preprocess_typecheck, _raise_typecheck_error, _check_caller_type, TypeAgent, \ - _check_as_func, is_Tuple + _check_as_func, is_Tuple, _get_orig_class from . import util, type_util try: @@ -796,7 +796,7 @@ def checker_tp(*args, **kw): parent_class = None if slf: try: - parent_class = args_kw[0].__orig_class__ + parent_class = _get_orig_class(args_kw[0]) except AttributeError: parent_class = args_kw[0].__class__ elif clsm: From d797019e0009cd169d3773a1dc6e52f9409063ae Mon Sep 17 00:00:00 2001 From: lubieowoce Date: Sun, 2 Sep 2018 21:04:36 +0200 Subject: [PATCH 2/3] Added tests that fail before and pass after fixing getattr (at least on my machine - Windows 10, Python 3.5) --- tests/test_typechecker.py | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_typechecker.py b/tests/test_typechecker.py index 1c66be1..22d86d4 100644 --- a/tests/test_typechecker.py +++ b/tests/test_typechecker.py @@ -1370,6 +1370,38 @@ def meth_2(self, c): return 3*len(c) +@typechecked +class GetAttrDictWrapper(object): + """Test a plausible use of __getattr__ - + A class that wraps a dict, enabling the values to be accessed as if they were attributes + (`d.abc` instead of `d['abc']`) + For example, the `pyrsistent` library does this on its dict replacement. + + >>> o = GetAttrDictWrapper({'a': 5, 'b': 10}) + >>> o.a + 5 + >>> o.b + 10 + >>> o.nonexistent + Traceback (most recent call last): + ... + AttributeError('nonexistent') + + """ + + def __init__(self, dct): + # type: (dict) -> None + self.__dct = dct + + def __getattr__(self, attr): + # type: (str) -> typing.Any + dct = self.__dct # can safely access the attribute because it exists so it won't trigger __getattr__ + try: + return dct[attr] + except KeyError: + raise AttributeError(attr) + + class TestTypecheck(unittest.TestCase): def test_function(self): self.assertEqual(testfunc(3, 2.5, 'abcd'), (9, 7.5)) @@ -2558,6 +2590,17 @@ def test_staticmethod(self): tc.testmeth_static2(11, ('a', 'b'), 1.9)) +class TestTypecheck_class_with_getattr(unittest.TestCase): + def test_valid_access(self): + obj = GetAttrDictWrapper({'a': 5, 'b': 10}) + self.assertEqual(obj.a, 5) + self.assertEqual(obj.b, 10) + + def test_invalid_access(self): + obj = GetAttrDictWrapper({'a': 5, 'b': 10}) + self.assertRaises(AttributeError, lambda: obj.nonexistent) + + class TestTypecheck_module(unittest.TestCase): def test_function_py2(self): from testhelpers import modulewide_typecheck_testhelper_py2 as mth From 26c24c037c106e4271804227756861fe8a897204 Mon Sep 17 00:00:00 2001 From: lubieowoce Date: Mon, 3 Sep 2018 00:02:57 +0200 Subject: [PATCH 3/3] Linked to commit hash --- tests/test_typechecker.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_typechecker.py b/tests/test_typechecker.py index 22d86d4..604b8f8 100644 --- a/tests/test_typechecker.py +++ b/tests/test_typechecker.py @@ -2591,6 +2591,12 @@ def test_staticmethod(self): class TestTypecheck_class_with_getattr(unittest.TestCase): + """ + See pull request: + https://github.com/Stewori/pytypes/pull/53 + commit #: + e2523b347e52707f87d7078daad1a93940c12e2e + """ def test_valid_access(self): obj = GetAttrDictWrapper({'a': 5, 'b': 10}) self.assertEqual(obj.a, 5)