diff --git a/Doc/library/inspect.rst b/Doc/library/inspect.rst --- a/Doc/library/inspect.rst +++ b/Doc/library/inspect.rst @@ -977,8 +977,28 @@ updated as expected: .. impl-detail:: This function relies on the generator exposing a Python stack frame for introspection, which isn't guaranteed to be the case in all implementations of Python. In such cases, this function will always return an empty dictionary. .. versionadded:: 3.3 + + +Helpers +------- + +.. function:: resolve_name(name) + + Resolve a name like ``module.object`` to an object and return it. + + :func:`resolve_name` supports packages and attributes without depth + limitation: ``package.package.module.class.function.attr`` is valid + input. + + :func:`resolve_name` does *not* support relative imports. Raise + :exc:`ValueError` if *name* is starts with `.`. + + Raise :exc:`ImportError` if importing the module fails or if one + requested attribute is not found. + + .. versionadded:: 3.4 diff --git a/Lib/inspect.py b/Lib/inspect.py --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -27,16 +27,17 @@ Here are some of the useful functions pr """ # This module is in the public domain. No warranties. __author__ = ('Ka-Ping Yee ', 'Yury Selivanov ') import imp +import importlib import importlib.machinery import itertools import linecache import os import re import sys import tokenize import types @@ -2066,8 +2067,40 @@ class Signature: rendered = '({})'.format(', '.join(result)) if self.return_annotation is not _empty: anno = formatannotation(self.return_annotation) rendered += ' -> {}'.format(anno) return rendered + + +def resolve_name(name): + """Resolve a name like ``module.object`` to an object and return it. + + :func:`resolve_name` supports packages and attributes without depth + limitation: ``package.package.module.class.function.attr`` is valid + input. + + :func:`resolve_name` does *not* support relative imports. Raise + :exc:`ValueError` if *name* is starts with `.`. + + Raise :exc:`ImportError` if importing the module fails or if one + requested attribute is not found. + + """ + if name.startswith('.'): + raise ValueError('Relative imports are not supported.') + if '.' not in name: + importlib.import_module(name) + return sys.modules[name] + names = name.split('.') + parent = names.pop(0) + found = importlib.import_module(parent) + for name in names: + submodule = '.'.join([parent, name]) + try: + found = getattr(found, name) + except AttributeError: + importlib.import_module(submodule) + found = getattr(found, name) + return found diff --git a/Lib/test/test_inspect.py b/Lib/test/test_inspect.py --- a/Lib/test/test_inspect.py +++ b/Lib/test/test_inspect.py @@ -3,18 +3,20 @@ import sys import types import unittest import inspect import linecache import datetime import collections import os import shutil -from os.path import normcase +import textwrap +from os.path import dirname, join, normcase +from test.script_helper import make_pkg, make_script, temp_dir from test.support import run_unittest, TESTFN, DirsOnSysPath from test import inspect_fodder as mod from test import inspect_fodder2 as mod2 # C module for test_findsource_binary import unicodedata @@ -2263,20 +2265,66 @@ class TestBoundArguments(unittest.TestCa ba3.arguments['a'] = 1 self.assertEqual(ba, ba3) def bar(b): pass ba4 = inspect.signature(bar).bind(1) self.assertNotEqual(ba, ba4) +class TestHelpers(unittest.TestCase): + def test_resolve_name_package(self): + # The test package structure: + # + # spam/ + # ├── eggs.py + # ├── __init__.py + dir = 'spam' + testdir = join(dirname(mod.__file__), 'data') + resolve_name = inspect.resolve_name + try: + make_pkg(join(testdir, dir), 'dummy_attr = True') + src = textwrap.dedent("""\ + def foo(): return 42 + """) + make_script(join(testdir, dir), 'eggs', src) + sys.path.insert(0, testdir) + self.assertEqual('spam', resolve_name('spam').__name__) + # In spam/__init__.py: + self.assertTrue(resolve_name('spam.dummy_attr')) + self.assertEqual('spam.eggs', resolve_name('spam.eggs').__name__) + eggs = resolve_name('spam.eggs') + self.assertEqual(42, eggs.foo()) + self.assertRaises(ImportError, resolve_name, 'spam.invalid') + finally: + sys.path.remove(testdir) + shutil.rmtree(join(testdir, dir)) + + def test_resolve_name_module(self): + testdir = join(dirname(mod.__file__), 'data') + module_name = 'foo' + resolve_name = inspect.resolve_name + try: + src = textwrap.dedent("""\ + def bar(): return 42 + """) + make_script(testdir, module_name, src) + sys.path.insert(0, testdir) + self.assertEqual('foo', resolve_name('foo').__name__) + foo = resolve_name('foo') + self.assertEqual(42, foo.bar()) + finally: + sys.path.remove(testdir) + os.unlink(join(testdir, module_name + os.extsep + 'py')) + + def test_main(): run_unittest( TestDecorators, TestRetrievingSourceCode, TestOneliners, TestBuggyCases, TestInterpreterStack, TestClassesAndFunctions, TestPredicates, TestGetcallargsFunctions, TestGetcallargsMethods, TestGetcallargsUnboundMethods, TestGetattrStatic, TestGetGeneratorState, TestNoEOL, TestSignatureObject, TestSignatureBind, TestParameterObject, - TestBoundArguments, TestGetClosureVars + TestBoundArguments, TestGetClosureVars, TestHelpers, ) if __name__ == "__main__": test_main()