diff --git a/jedi/_compatibility.py b/jedi/_compatibility.py index e468dd4d..065fde89 100644 --- a/jedi/_compatibility.py +++ b/jedi/_compatibility.py @@ -684,3 +684,45 @@ if not is_py3: atexit.register(finalize._exitfunc) weakref.finalize = finalize + + +if is_py3 and sys.version_info[1] > 5: + from inspect import unwrap +else: + # Only Python >=3.6 does properly limit the amount of unwraps. This is very + # relevant in the case of unittest.mock.patch. + # Below is the implementation of Python 3.7. + def unwrap(func, stop=None): + """Get the object wrapped by *func*. + + Follows the chain of :attr:`__wrapped__` attributes returning the last + object in the chain. + + *stop* is an optional callback accepting an object in the wrapper chain + as its sole argument that allows the unwrapping to be terminated early if + the callback returns a true value. If the callback never returns a true + value, the last object in the chain is returned as usual. For example, + :func:`signature` uses this to stop unwrapping if any object in the + chain has a ``__signature__`` attribute defined. + + :exc:`ValueError` is raised if a cycle is encountered. + + """ + if stop is None: + def _is_wrapper(f): + return hasattr(f, '__wrapped__') + else: + def _is_wrapper(f): + return hasattr(f, '__wrapped__') and not stop(f) + f = func # remember the original func for error reporting + # Memoise by id to tolerate non-hashable objects, but store objects to + # ensure they aren't destroyed, which would allow their IDs to be reused. + memo = {id(f): f} + recursion_limit = sys.getrecursionlimit() + while _is_wrapper(func): + func = func.__wrapped__ + id_func = id(func) + if (id_func in memo) or (len(memo) >= recursion_limit): + raise ValueError('wrapper loop when unwrapping {!r}'.format(f)) + memo[id_func] = func + return func diff --git a/jedi/inference/compiled/mixed.py b/jedi/inference/compiled/mixed.py index 4682bff1..3087914c 100644 --- a/jedi/inference/compiled/mixed.py +++ b/jedi/inference/compiled/mixed.py @@ -8,6 +8,7 @@ import sys from jedi.parser_utils import get_cached_code_lines +from jedi._compatibility import unwrap from jedi import settings from jedi.inference import compiled from jedi.cache import underscore_memoization @@ -160,7 +161,11 @@ def _load_module(inference_state, path): def _get_object_to_check(python_object): """Check if inspect.getfile has a chance to find the source.""" if sys.version_info[0] > 2: - python_object = inspect.unwrap(python_object) + try: + python_object = unwrap(python_object) + except ValueError: + # Can return a ValueError when it wraps around + pass if (inspect.ismodule(python_object) or inspect.isclass(python_object) or diff --git a/test/test_api/test_interpreter.py b/test/test_api/test_interpreter.py index f108cc7e..df1b59f4 100644 --- a/test/test_api/test_interpreter.py +++ b/test/test_api/test_interpreter.py @@ -466,10 +466,10 @@ def test__wrapped__(): assert c.line == syslogs_to_df.__wrapped__.__code__.co_firstlineno + 1 -@pytest.mark.parametrize('module_name', ['sys', 'time']) +@pytest.mark.parametrize('module_name', ['sys', 'time', 'unittest.mock']) def test_core_module_completes(module_name): module = import_module(module_name) - assert jedi.Interpreter(module_name + '.\n', [locals()]).completions() + assert jedi.Interpreter('module.', [locals()]).completions() @pytest.mark.skipif(sys.version_info[0] == 2, reason="Ignore Python 2, because EOL")