diff options
-rw-r--r-- | changelog.d/1753.change.rst | 5 | ||||
-rw-r--r-- | docs/setuptools.txt | 27 | ||||
-rw-r--r-- | setuptools/config.py | 87 | ||||
-rw-r--r-- | setuptools/tests/test_config.py | 54 |
4 files changed, 57 insertions, 116 deletions
diff --git a/changelog.d/1753.change.rst b/changelog.d/1753.change.rst index 0f27bb2e..c8b68026 100644 --- a/changelog.d/1753.change.rst +++ b/changelog.d/1753.change.rst @@ -1 +1,4 @@ -Added a ``literal_attr:`` config directive to support reading versions from attributes of modules that import third-party modules +``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/setuptools.txt b/docs/setuptools.txt index 3e616582..c37b7ec5 100644 --- a/docs/setuptools.txt +++ b/docs/setuptools.txt @@ -2193,7 +2193,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:``, ``literal_attr:``, ``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. @@ -2291,13 +2291,10 @@ Special directives: * ``attr:`` - Value is read from a module attribute. ``attr:`` supports callables and iterables; unsupported types are cast using ``str()``. -* ``literal_attr:`` — Like ``attr:``, except that the value is parsed using - ``ast.literal_eval()`` instead of by importing the module. This allows one - to specify an attribute of a module that imports one or more third-party - modules without having to install those modules first; as a downside, - ``literal_attr:`` only supports variables that are assigned constant - expressions, not more complex assignments like ``__version__ = - '.'.join(map(str, (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH)))``. + 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 @@ -2314,14 +2311,14 @@ Metadata The aliases given below are supported for compatibility reasons, but their use is not advised. -============================== ================= ================================ =============== ===== -Key Aliases Type Minimum Version Notes -============================== ================= ================================ =============== ===== +============================== ================= ================= =============== ===== +Key Aliases Type Minimum Version Notes +============================== ================= ================= =============== ===== name str -version attr:, literal_attr:, file:, str 39.2.0 (1) +version attr:, file:, str 39.2.0 (1) url home-page str download_url download-url str -project_urls dict 38.3.0 +project_urls dict 38.3.0 author str author_email author-email str maintainer str @@ -2332,13 +2329,13 @@ license_file str license_files list-comma description summary file:, str long_description long-description file:, str -long_description_content_type str 38.6.0 +long_description_content_type str 38.6.0 keywords list-comma platforms platform list-comma provides list-comma requires list-comma obsoletes list-comma -============================== ================= ================================ =============== ===== +============================== ================= ================= =============== ===== .. note:: A version loaded using the ``file:`` directive must comply with PEP 440. diff --git a/setuptools/config.py b/setuptools/config.py index d1456cac..0a2f51e2 100644 --- a/setuptools/config.py +++ b/setuptools/config.py @@ -317,22 +317,15 @@ class ConfigHandler: Examples: attr: package.attr attr: package.module.attr - literal_attr: package.attr - literal_attr: package.module.attr :param str value: :rtype: str """ attr_directive = 'attr:' - literal_attr_directive = 'literal_attr:' - if value.startswith(attr_directive): - directive = attr_directive - elif value.startswith(literal_attr_directive): - directive = literal_attr_directive - else: + if not value.startswith(attr_directive): return value - attrs_path = value.replace(directive, '').strip().split('.') + attrs_path = value.replace(attr_directive, '').strip().split('.') attr_name = attrs_path.pop() module_name = '.'.join(attrs_path) @@ -352,50 +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['']) - if directive == attr_directive: + + 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:] - - elif directive == literal_attr_directive: - 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: - value = ast.literal_eval(statement.value) - found = True - elif isinstance(target, ast.Tuple) \ - and any(isinstance(t, ast.Name) and t.id==attr_name - for t in target.elts): - stmnt_value = ast.literal_eval(statement.value) - 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: - raise DistutilsOptionError( - 'No literal assignment to {!r} found in file' - .format(attr_name) - ) - return value @classmethod diff --git a/setuptools/tests/test_config.py b/setuptools/tests/test_config.py index 03e6916b..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,25 +300,6 @@ class TestMetadata: with get_dist(tmpdir) as dist: assert dist.metadata.version == '2016.11.26' - def test_literal_version(self, tmpdir): - - _, config = fake_env( - tmpdir, - '[metadata]\n' - 'version = literal_attr: fake_package.VERSION\n' - ) - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '1.2.3' - - config.write( - '[metadata]\n' - 'version = literal_attr: fake_package.VERSION_MAJOR\n' - ) - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '1' - - subpack = tmpdir.join('fake_package').mkdir('subpackage') - subpack.join('__init__.py').write('') subpack.join('submodule.py').write( 'import third_party_module\n' 'VERSION = (2016, 11, 26)' @@ -363,17 +344,6 @@ class TestMetadata: with get_dist(tmpdir) as dist: assert dist.metadata.version == '1.2.3' - config.write( - '[metadata]\n' - 'version = literal_attr: fake_package_simple.VERSION\n' - '[options]\n' - 'package_dir =\n' - ' = src\n' - ) - - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '1.2.3' - def test_version_with_package_dir_rename(self, tmpdir): _, config = fake_env( @@ -389,17 +359,6 @@ class TestMetadata: with get_dist(tmpdir) as dist: assert dist.metadata.version == '1.2.3' - config.write( - '[metadata]\n' - 'version = literal_attr: fake_package_rename.VERSION\n' - '[options]\n' - 'package_dir =\n' - ' fake_package_rename = fake_dir\n' - ) - - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '1.2.3' - def test_version_with_package_dir_complex(self, tmpdir): _, config = fake_env( @@ -415,17 +374,6 @@ class TestMetadata: with get_dist(tmpdir) as dist: assert dist.metadata.version == '1.2.3' - config.write( - '[metadata]\n' - 'version = literal_attr: fake_package_complex.VERSION\n' - '[options]\n' - 'package_dir =\n' - ' fake_package_complex = src/fake_dir\n' - ) - - with get_dist(tmpdir) as dist: - assert dist.metadata.version == '1.2.3' - def test_unknown_meta_item(self, tmpdir): fake_env( |