diff --git a/Doc/packaging/commandhooks.rst b/Doc/packaging/commandhooks.rst --- a/Doc/packaging/commandhooks.rst +++ b/Doc/packaging/commandhooks.rst @@ -9,7 +9,9 @@ Command hooks Packaging provides a way of extending its commands by the use of pre- and post-command hooks. Hooks are Python functions (or any callable object) that take a command object as argument. They're specified in :ref:`config files -` using their fully qualified names. After a +` using their fully qualified names. The names +must be defined at module level; in order to use for example a nested class as +hook, you will have to make a module-level alias for it. After a command is finalized (its options are processed), the pre-command hooks are executed, then the command itself is run, and finally the post-command hooks are executed. diff --git a/Doc/packaging/setupcfg.rst b/Doc/packaging/setupcfg.rst --- a/Doc/packaging/setupcfg.rst +++ b/Doc/packaging/setupcfg.rst @@ -185,7 +185,8 @@ compilers setup_hooks Defines a list of callables to be called right after the :file:`setup.cfg` file is read, before any other processing. Each value is a Python dotted - name to an object, which has to be defined in a module present in the project + name to an object; it must be a module-level name. Modules containing hooks + can be located in the project directory alonside :file:`setup.cfg` or on Python's :data:`sys.path` (see :ref:`packaging-finding-hooks`). The callables are executed in the order they're found in the file; if one of them cannot be found, tools should diff --git a/Lib/packaging/command/__init__.py b/Lib/packaging/command/__init__.py --- a/Lib/packaging/command/__init__.py +++ b/Lib/packaging/command/__init__.py @@ -1,7 +1,7 @@ """Subpackage containing all standard commands.""" import os from packaging.errors import PackagingModuleError -from packaging.util import resolve_name +from packaging.util import find_object __all__ = ['get_command_names', 'set_command', 'get_command_class', 'STANDARD_COMMANDS'] @@ -46,7 +46,7 @@ def get_command_names(): def set_command(location): - cls = resolve_name(location) + cls = find_object(location) # XXX we want to do the duck-type checking here _COMMANDS[cls.get_command_name()] = cls @@ -58,6 +58,6 @@ def get_command_class(name): except KeyError: raise PackagingModuleError("Invalid command %s" % name) if isinstance(cls, str): - cls = resolve_name(cls) + cls = find_object(cls) _COMMANDS[name] = cls return cls diff --git a/Lib/packaging/command/check.py b/Lib/packaging/command/check.py --- a/Lib/packaging/command/check.py +++ b/Lib/packaging/command/check.py @@ -3,7 +3,7 @@ from packaging import logger from packaging.command.cmd import Command from packaging.errors import PackagingSetupError -from packaging.util import resolve_name +from packaging.util import find_object class check(Command): @@ -83,6 +83,6 @@ class check(Command): break for hook_name in options[hook_kind][1].values(): try: - resolve_name(hook_name) + find_object(hook_name) except ImportError: self.warn('name %r cannot be resolved', hook_name) diff --git a/Lib/packaging/command/sdist.py b/Lib/packaging/command/sdist.py --- a/Lib/packaging/command/sdist.py +++ b/Lib/packaging/command/sdist.py @@ -7,7 +7,7 @@ from io import StringIO from shutil import get_archive_formats, rmtree from packaging import logger -from packaging.util import resolve_name +from packaging.util import find_object from packaging.errors import (PackagingPlatformError, PackagingOptionError, PackagingModuleError, PackagingFileError) from packaging.command import get_command_names @@ -142,7 +142,7 @@ class sdist(Command): if builder == '': continue try: - builder = resolve_name(builder) + builder = find_object(builder) except ImportError as e: raise PackagingModuleError(e) diff --git a/Lib/packaging/command/test.py b/Lib/packaging/command/test.py --- a/Lib/packaging/command/test.py +++ b/Lib/packaging/command/test.py @@ -9,7 +9,7 @@ from packaging import logger from packaging.command.cmd import Command from packaging.database import get_distribution from packaging.errors import PackagingOptionError -from packaging.util import resolve_name +from packaging.util import find_object class test(Command): @@ -67,10 +67,10 @@ class test(Command): # run the tests if self.runner: - resolve_name(self.runner)() + find_object(self.runner)() elif self.suite: runner = unittest.TextTestRunner(verbosity=verbosity) - runner.run(resolve_name(self.suite)()) + runner.run(find_object(self.suite)()) elif self.get_ut_with_discovery(): ut = self.get_ut_with_discovery() test_suite = ut.TestLoader().discover(os.curdir) diff --git a/Lib/packaging/compiler/__init__.py b/Lib/packaging/compiler/__init__.py --- a/Lib/packaging/compiler/__init__.py +++ b/Lib/packaging/compiler/__init__.py @@ -18,7 +18,7 @@ import sys import re import sysconfig -from packaging.util import resolve_name +from packaging.util import find_object from packaging.errors import PackagingPlatformError from packaging import logger @@ -129,7 +129,7 @@ _COMPILERS = { def set_compiler(location): """Add or change a compiler""" - cls = resolve_name(location) + cls = find_object(location) # XXX we want to check the class here _COMPILERS[cls.name] = cls @@ -143,7 +143,7 @@ def show_compilers(): for name, cls in _COMPILERS.items(): if isinstance(cls, str): - cls = resolve_name(cls) + cls = find_object(cls) _COMPILERS[name] = cls compilers.append(("compiler=" + name, None, cls.description)) @@ -179,7 +179,7 @@ def new_compiler(plat=None, compiler=Non raise PackagingPlatformError(msg) if isinstance(cls, str): - cls = resolve_name(cls) + cls = find_object(cls) _COMPILERS[compiler] = cls return cls(dry_run, force) diff --git a/Lib/packaging/config.py b/Lib/packaging/config.py --- a/Lib/packaging/config.py +++ b/Lib/packaging/config.py @@ -9,7 +9,7 @@ from configparser import RawConfigParser from packaging import logger from packaging.errors import PackagingOptionError from packaging.compiler.extension import Extension -from packaging.util import (check_environ, iglob, resolve_name, strtobool, +from packaging.util import (check_environ, iglob, find_object, strtobool, split_multiline) from packaging.compiler import set_compiler from packaging.command import set_command @@ -153,7 +153,7 @@ class Config: try: for line in setup_hooks: try: - hook = resolve_name(line) + hook = find_object(line) except ImportError as e: logger.warning('cannot find setup hook: %s', e.args[0]) diff --git a/Lib/packaging/dist.py b/Lib/packaging/dist.py --- a/Lib/packaging/dist.py +++ b/Lib/packaging/dist.py @@ -4,7 +4,7 @@ import os import re from packaging import logger -from packaging.util import strtobool, resolve_name +from packaging.util import strtobool, find_object from packaging.config import Config from packaging.errors import (PackagingOptionError, PackagingArgError, PackagingModuleError, PackagingClassError) @@ -728,7 +728,7 @@ Common commands: (see '--help-commands' for hook in hooks.values(): if isinstance(hook, str): try: - hook_obj = resolve_name(hook) + hook_obj = find_object(hook) except ImportError as e: raise PackagingModuleError(e) else: diff --git a/Lib/packaging/tests/support.py b/Lib/packaging/tests/support.py --- a/Lib/packaging/tests/support.py +++ b/Lib/packaging/tests/support.py @@ -41,7 +41,7 @@ import tempfile import sysconfig from packaging.dist import Distribution -from packaging.util import resolve_name +from packaging.util import find_object from packaging.command import set_command, _COMMANDS from packaging.tests import unittest @@ -58,6 +58,7 @@ __all__ = [ 'copy_xxmodule_c', 'fixup_build_ext', # imported from this module for backport purposes 'unittest', 'requires_zlib', 'skip_2to3_optimize', 'skip_unless_symlink', + 'unload' ] @@ -304,7 +305,7 @@ def use_command(testcase, fullname): """Register command at *fullname* for the duration of a test.""" set_command(fullname) # XXX maybe set_command should return the class object - name = resolve_name(fullname).get_command_name() + name = find_object(fullname).get_command_name() # XXX maybe we need a public API to remove commands testcase.addCleanup(_COMMANDS.__delitem__, name) @@ -381,6 +382,14 @@ def fixup_build_ext(cmd): cmd.library_dirs = value.split(os.pathsep) +def unload(name): + """Remove *name* from the module cache.""" + try: + del sys.modules[name] + except KeyError: + pass + + try: from test.support import skip_unless_symlink except ImportError: diff --git a/Lib/packaging/tests/test_dist.py b/Lib/packaging/tests/test_dist.py --- a/Lib/packaging/tests/test_dist.py +++ b/Lib/packaging/tests/test_dist.py @@ -10,7 +10,6 @@ from packaging.command.cmd import Comman from packaging.errors import PackagingModuleError, PackagingOptionError from packaging.tests import support, unittest from packaging.tests.support import create_distribution, use_command -from test.support import unload class test_dist(Command): @@ -214,7 +213,7 @@ class DistributionTestCase(support.Tempd # prepare the call recorders sys.path.append(temp_home) self.addCleanup(sys.path.remove, temp_home) - self.addCleanup(unload, module_name) + self.addCleanup(support.unload, module_name) record = __import__(module_name).record cmd.run = lambda: record.append('run') diff --git a/Lib/packaging/tests/test_util.py b/Lib/packaging/tests/test_util.py --- a/Lib/packaging/tests/test_util.py +++ b/Lib/packaging/tests/test_util.py @@ -17,7 +17,7 @@ from packaging.dist import Distribution from packaging.util import ( convert_path, change_root, split_quoted, strtobool, run_2to3, get_compiler_versions, _MAC_OS_X_LD_VERSION, byte_compile, find_packages, - spawn, get_pypirc_path, generate_pypirc, read_pypirc, resolve_name, iglob, + spawn, get_pypirc_path, generate_pypirc, read_pypirc, find_object, iglob, RICH_GLOB, egginfo_to_distinfo, is_setuptools, is_distutils, is_packaging, get_install_method, cfg_to_args, generate_setup_py, encode_multipart) @@ -372,49 +372,76 @@ class UtilTestCase(support.EnvironRestor self.assertEqual(sorted(res), ['pkg1', 'pkg1.pkg3', 'pkg1.pkg3.pkg6', 'pkg5']) - def test_resolve_name(self): - # test raw module name + def test_find_object(self): tmpdir = self.mkdtemp() sys.path.append(tmpdir) self.addCleanup(sys.path.remove, tmpdir) - self.write_file((tmpdir, 'hello.py'), '') + # XXX should we remove imported modules from sys.modules? + self.write_file((tmpdir, 'hello.py'), 'a = 42') os.makedirs(os.path.join(tmpdir, 'a', 'b')) self.write_file((tmpdir, 'a', '__init__.py'), '') self.write_file((tmpdir, 'a', 'b', '__init__.py'), '') - self.write_file((tmpdir, 'a', 'b', 'c.py'), 'class Foo: pass') - self.write_file((tmpdir, 'a', 'b', 'd.py'), textwrap.dedent("""\ - class FooBar: + self.write_file((tmpdir, 'a', 'b', 'c.py'), textwrap.dedent("""\ + class Foo: class Bar: - def baz(self): - pass + pass """)) - # check Python, C and built-in module - self.assertEqual(resolve_name('hello').__name__, 'hello') - self.assertEqual(resolve_name('_csv').__name__, '_csv') - self.assertEqual(resolve_name('sys').__name__, 'sys') - - # test module.attr - self.assertIs(resolve_name('builtins.str'), str) - self.assertIsNone(resolve_name('hello.__doc__')) - self.assertEqual(resolve_name('a.b.c.Foo').__name__, 'Foo') - self.assertEqual(resolve_name('a.b.d.FooBar.Bar.baz').__name__, 'baz') + self.assertIs(find_object('builtins.str'), str) + self.assertEqual(find_object('hello.a'), 42) + self.assertEqual(find_object('a.b.c.Foo').__name__, 'Foo') # error if module not found - self.assertRaises(ImportError, resolve_name, 'nonexistent') - self.assertRaises(ImportError, resolve_name, 'non.existent') - self.assertRaises(ImportError, resolve_name, 'a.no') - self.assertRaises(ImportError, resolve_name, 'a.b.no') - self.assertRaises(ImportError, resolve_name, 'a.b.no.no') - self.assertRaises(ImportError, resolve_name, 'inva-lid') + self.assertRaises(ImportError, find_object, 'non.existent') + self.assertRaises(ImportError, find_object, 'a.no') + self.assertRaises(ImportError, find_object, 'a.b.no') + self.assertRaises(ImportError, find_object, 'a.b.no.no') + self.assertRaises(ImportError, find_object, 'a.inva-lid.hop') - # looking up built-in names is not supported - self.assertRaises(ImportError, resolve_name, 'str') + # lone module name or unqualified builtin name not supported + self.assertRaises(ImportError, find_object, 'a') + self.assertRaises(ImportError, find_object, 'str') + # this argument is invalid but used to work, because getattr (in + # find_object) succeeds if the submodule was previously imported + self.assertRaises(ImportError, find_object, 'a.b') + + # nesting not supported + self.assertRaises(ImportError, find_object, 'a.b.c.Foo.Bar') # error if module found but not attr - self.assertRaises(ImportError, resolve_name, 'a.b.Spam') - self.assertRaises(ImportError, resolve_name, 'a.b.c.Spam') + self.assertRaises(ImportError, find_object, 'a.b.Spam') + self.assertRaises(ImportError, find_object, 'a.b.c.Spam') + + # make sure exceptions pass through instead of being wrapped into + # ImportErrors which would lose useful information + self.write_file((tmpdir, 'syntaxerror.py'), 'mport unittest') + self.write_file((tmpdir, 'a', 'syntaxerror.py'), 'mport unittest') + self.write_file((tmpdir, 'importerror.py'), 'import MAGIC') + self.write_file((tmpdir, 'a', 'importerror.py'), 'import MAGIC') + self.write_file((tmpdir, 'attributeerror.py'), 'x = str.magic') + self.write_file((tmpdir, 'a', 'attributeerror.py'), 'x = str.magic') + customerror = textwrap.dedent("""\ + class Error(Exception): + pass + + raise Error('very useful error message') + """) + self.write_file((tmpdir, 'customerror.py'), customerror) + self.write_file((tmpdir, 'a', 'customerror.py'), customerror) + + tests = ( + ('syntaxerror.x', SyntaxError, 'invalid syntax.*line 1'), + ('a.syntaxerror.x', SyntaxError, 'invalid syntax.*line 1'), + ('importerror.x', ImportError, "No module named '?MAGIC'?"), + ('a.importerror.x', ImportError, "No module named '?MAGIC'?"), + ('attributeerror.x', AttributeError, "no attribute 'magic'"), + ('a.attributeerror.x', AttributeError, "no attribute 'magic'"), + ('customerror.x', Exception, 'very useful error message'), + ('a.customerror.x', Exception, 'very useful error message'), + ) + for name, exc, msg in tests: + self.assertRaisesRegex(exc, msg, find_object, name) @support.skip_2to3_optimize def test_run_2to3_on_code(self): diff --git a/Lib/packaging/util.py b/Lib/packaging/util.py --- a/Lib/packaging/util.py +++ b/Lib/packaging/util.py @@ -39,7 +39,7 @@ __all__ = [ 'egginfo_to_distinfo', 'get_install_method', # misc - 'ask', 'check_environ', 'encode_multipart', 'resolve_name', + 'ask', 'check_environ', 'encode_multipart', 'find_object', # querying for information TODO move to sysconfig 'get_compiler_versions', 'get_platform', 'set_platform', # configuration TODO move to packaging.config @@ -625,46 +625,41 @@ def find_packages(paths=(os.curdir,), ex return packages -def resolve_name(name): - """Resolve a name like ``module.object`` to an object and return it. +def find_object(path): + """Resolve a path like 'module.object' to an object and return it. - This functions supports packages and attributes without depth limitation: - ``package.package.module.class.class.function.attr`` is valid input. - However, looking up builtins is not directly supported: use - ``builtins.name``. + This functions supports packages and attributes without depth + limitation, but requires exactly one level inside the module. Thus, + 'package.package.module.anything' is valid input, but it is not + possible to look up nested classes, methods or other nested attributes, + and it is not possible to look up just a module name (use __import__ + + sys.modules) or a builtin name (use find_object('builtins.name'). - Raises ImportError if importing the module fails or if one requested - attribute is not found. + Raises ImportError if the argument does not respect the rules, if + importing the module fails or if one requested attribute is not found. """ - if '.' not in name: - # shortcut - __import__(name) - return sys.modules[name] + # it would have been nice to raise ValueError for malformed arguments + # (like 'module' or 'builtin'), but it's not trivial to intercept + # AttributeError and detect cases like 'package.module' (misses a local + # part) or 'module.class.nestedclass', so we always raise ImportError - # FIXME clean up this code! - parts = name.split('.') - cursor = len(parts) - module_name = parts[:cursor] - ret = '' + if '.' not in path: + raise ImportError('need at least one dot (got %r)' % path) + if path in sys.modules: + # refuse the module name here, otherwise getattr would succeed + raise ImportError('need a name after the module part (got %r)' % path) - while cursor > 0: - try: - ret = __import__('.'.join(module_name)) - break - except ImportError: - cursor -= 1 - module_name = parts[:cursor] + parts = path.split('.') + name = parts.pop(-1) + modulename = '.'.join(parts) - if ret == '': - raise ImportError(parts[0]) + __import__(modulename) + module = sys.modules[modulename] - for part in parts[1:]: - try: - ret = getattr(ret, part) - except AttributeError as exc: - raise ImportError(exc) - - return ret + try: + return getattr(module, name) + except AttributeError as exc: + raise ImportError(exc) def splitext(path):