diff options
-rw-r--r-- | changelog.d/1753.change.rst | 4 | ||||
-rw-r--r-- | docs/userguide/declarative_config.txt | 10 | ||||
-rw-r--r-- | setuptools/config.py | 51 | ||||
-rw-r--r-- | setuptools/tests/test_config.py | 14 |
4 files changed, 69 insertions, 10 deletions
diff --git a/changelog.d/1753.change.rst b/changelog.d/1753.change.rst new file mode 100644 index 00000000..c8b68026 --- /dev/null +++ b/changelog.d/1753.change.rst @@ -0,0 +1,4 @@ +``attr:`` now extracts variables through rudimentary examination of the AST, +thereby supporting modules with third-party imports. If examining the AST +fails to find the variable, ``attr:`` falls back to the old behavior of +importing the module. diff --git a/docs/userguide/declarative_config.txt b/docs/userguide/declarative_config.txt index b40d3a4a..2aa1c717 100644 --- a/docs/userguide/declarative_config.txt +++ b/docs/userguide/declarative_config.txt @@ -92,7 +92,7 @@ Metadata and options are set in the config sections of the same name. * In some cases, complex values can be provided in dedicated subsections for clarity. -* Some keys allow ``file:``, ``attr:``, and ``find:`` and ``find_namespace:`` directives in +* Some keys allow ``file:``, ``attr:``, ``find:``, and ``find_namespace:`` directives in order to cover common usecases. * Unknown keys are ignored. @@ -146,6 +146,12 @@ Special directives: * ``attr:`` - Value is read from a module attribute. ``attr:`` supports callables and iterables; unsupported types are cast using ``str()``. + + In order to support the common case of a literal value assigned to a variable + in a module containing (directly or indirectly) third-party imports, + ``attr:`` first tries to read the value from the module by examining the + module's AST. If that fails, ``attr:`` falls back to importing the module. + * ``file:`` - Value is read from a list of files and then concatenated @@ -237,4 +243,4 @@ data_files dict 40.6.0 Notes: 1. In the `package_data` section, a key named with a single asterisk (`*`) -refers to all packages, in lieu of the empty string used in `setup.py`.
\ No newline at end of file +refers to all packages, in lieu of the empty string used in `setup.py`. diff --git a/setuptools/config.py b/setuptools/config.py index 9b9a0c45..0a2f51e2 100644 --- a/setuptools/config.py +++ b/setuptools/config.py @@ -1,4 +1,5 @@ from __future__ import absolute_import, unicode_literals +import ast import io import os import sys @@ -344,14 +345,50 @@ class ConfigHandler: elif '' in package_dir: # A custom parent directory was specified for all root modules parent_path = os.path.join(os.getcwd(), package_dir['']) - sys.path.insert(0, parent_path) - try: - module = import_module(module_name) - value = getattr(module, attr_name) - - finally: - sys.path = sys.path[1:] + fpath = os.path.join(parent_path, *module_name.split('.')) + if os.path.exists(fpath + '.py'): + fpath += '.py' + elif os.path.isdir(fpath): + fpath = os.path.join(fpath, '__init__.py') + else: + raise DistutilsOptionError('Could not find module ' + module_name) + with open(fpath, 'rb') as fp: + src = fp.read() + found = False + top_level = ast.parse(src) + for statement in top_level.body: + if isinstance(statement, ast.Assign): + for target in statement.targets: + if isinstance(target, ast.Name) \ + and target.id == attr_name: + try: + value = ast.literal_eval(statement.value) + except ValueError: + found = False + else: + found = True + elif isinstance(target, ast.Tuple) \ + and any(isinstance(t, ast.Name) and t.id == attr_name + for t in target.elts): + try: + stmnt_value = ast.literal_eval(statement.value) + except ValueError: + found = False + else: + for t, v in zip(target.elts, stmnt_value): + if isinstance(t, ast.Name) \ + and t.id == attr_name: + value = v + found = True + if not found: + # Fall back to extracting attribute via importing + sys.path.insert(0, parent_path) + try: + module = import_module(module_name) + value = getattr(module, attr_name) + finally: + sys.path = sys.path[1:] return value @classmethod diff --git a/setuptools/tests/test_config.py b/setuptools/tests/test_config.py index 2fa0b374..d8347c78 100644 --- a/setuptools/tests/test_config.py +++ b/setuptools/tests/test_config.py @@ -103,7 +103,7 @@ class TestConfigurationReader: 'version = attr: none.VERSION\n' 'keywords = one, two\n' ) - with pytest.raises(ImportError): + with pytest.raises(DistutilsOptionError): read_configuration('%s' % config) config_dict = read_configuration( @@ -300,6 +300,18 @@ class TestMetadata: with get_dist(tmpdir) as dist: assert dist.metadata.version == '2016.11.26' + subpack.join('submodule.py').write( + 'import third_party_module\n' + 'VERSION = (2016, 11, 26)' + ) + + config.write( + '[metadata]\n' + 'version = attr: fake_package.subpackage.submodule.VERSION\n' + ) + with get_dist(tmpdir) as dist: + assert dist.metadata.version == '2016.11.26' + def test_version_file(self, tmpdir): _, config = fake_env( |