summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--changelog.d/1753.change.rst4
-rw-r--r--docs/setuptools.txt8
-rw-r--r--setuptools/config.py57
-rw-r--r--setuptools/tests/test_config.py32
4 files changed, 87 insertions, 14 deletions
diff --git a/changelog.d/1753.change.rst b/changelog.d/1753.change.rst
new file mode 100644
index 00000000..08fa9ea4
--- /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. Works on Python 3 only.
diff --git a/docs/setuptools.txt b/docs/setuptools.txt
index ec58b754..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:``, 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.
@@ -2290,6 +2290,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
diff --git a/setuptools/config.py b/setuptools/config.py
index 9b9a0c45..45df2e3f 100644
--- a/setuptools/config.py
+++ b/setuptools/config.py
@@ -1,14 +1,16 @@
from __future__ import absolute_import, unicode_literals
+import ast
import io
import os
import sys
import warnings
import functools
+import importlib
from collections import defaultdict
from functools import partial
from functools import wraps
-from importlib import import_module
+import contextlib
from distutils.errors import DistutilsOptionError, DistutilsFileError
from setuptools.extern.packaging.version import LegacyVersion, parse
@@ -19,6 +21,44 @@ from setuptools.extern.six import string_types, PY3
__metaclass__ = type
+class StaticModule:
+ """
+ Attempt to load the module by the name
+ """
+ def __init__(self, name):
+ spec = importlib.util.find_spec(name)
+ with open(spec.origin) as strm:
+ src = strm.read()
+ module = ast.parse(src)
+ vars(self).update(locals())
+ del self.self
+
+ def __getattr__(self, attr):
+ try:
+ return next(
+ ast.literal_eval(statement.value)
+ for statement in self.module.body
+ if isinstance(statement, ast.Assign)
+ for target in statement.targets
+ if isinstance(target, ast.Name) and target.id == attr
+ )
+ except Exception:
+ raise AttributeError(
+ "{self.name} has no attribute {attr}".format(**locals()))
+
+
+@contextlib.contextmanager
+def patch_path(path):
+ """
+ Add path to front of sys.path for the duration of the context.
+ """
+ try:
+ sys.path.insert(0, path)
+ yield
+ finally:
+ sys.path.remove(path)
+
+
def read_configuration(
filepath, find_others=False, ignore_option_errors=False):
"""Read given configuration file and returns options from it as a dict.
@@ -344,15 +384,16 @@ 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:]
+ with patch_path(parent_path):
+ try:
+ # attempt to load value statically
+ return getattr(StaticModule(module_name), attr_name)
+ except Exception:
+ # fallback to simple import
+ module = importlib.import_module(module_name)
- return value
+ return getattr(module, attr_name)
@classmethod
def _get_parser_compound(cls, *parse_methods):
diff --git a/setuptools/tests/test_config.py b/setuptools/tests/test_config.py
index 2fa0b374..67992c04 100644
--- a/setuptools/tests/test_config.py
+++ b/setuptools/tests/test_config.py
@@ -2,6 +2,7 @@
from __future__ import unicode_literals
import contextlib
+
import pytest
from distutils.errors import DistutilsOptionError, DistutilsFileError
@@ -9,6 +10,7 @@ from mock import patch
from setuptools.dist import Distribution, _Distribution
from setuptools.config import ConfigHandler, read_configuration
from setuptools.extern.six.moves import configparser
+from setuptools.extern import six
from . import py2_only, py3_only
from .textwrap import DALS
@@ -53,6 +55,7 @@ def fake_env(
' return [3, 4, 5, "dev"]\n'
'\n'
)
+
return package_dir, config
@@ -267,11 +270,23 @@ class TestMetadata:
def test_version(self, tmpdir):
- _, config = fake_env(
+ package_dir, config = fake_env(
tmpdir,
'[metadata]\n'
'version = attr: fake_package.VERSION\n'
)
+
+ sub_a = package_dir.mkdir('subpkg_a')
+ sub_a.join('__init__.py').write('')
+ sub_a.join('mod.py').write('VERSION = (2016, 11, 26)')
+
+ sub_b = package_dir.mkdir('subpkg_b')
+ sub_b.join('__init__.py').write('')
+ sub_b.join('mod.py').write(
+ 'import third_party_module\n'
+ 'VERSION = (2016, 11, 26)'
+ )
+
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1.2.3'
@@ -289,13 +304,20 @@ class TestMetadata:
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('VERSION = (2016, 11, 26)')
+ config.write(
+ '[metadata]\n'
+ 'version = attr: fake_package.subpkg_a.mod.VERSION\n'
+ )
+ with get_dist(tmpdir) as dist:
+ assert dist.metadata.version == '2016.11.26'
+
+ if six.PY2:
+ # static version loading is unsupported on Python 2
+ return
config.write(
'[metadata]\n'
- 'version = attr: fake_package.subpackage.submodule.VERSION\n'
+ 'version = attr: fake_package.subpkg_b.mod.VERSION\n'
)
with get_dist(tmpdir) as dist:
assert dist.metadata.version == '2016.11.26'