Skip to content

Commit c457e68

Browse files
authored
Merge pull request #1753 from jwodder/feature/literal_attr
Support attr directive with static evaluation
2 parents f9364aa + 39a37c0 commit c457e68

File tree

4 files changed

+87
-14
lines changed

4 files changed

+87
-14
lines changed

changelog.d/1753.change.rst

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
``attr:`` now extracts variables through rudimentary examination of the AST,
2+
thereby supporting modules with third-party imports. If examining the AST
3+
fails to find the variable, ``attr:`` falls back to the old behavior of
4+
importing the module. Works on Python 3 only.

docs/setuptools.txt

+7-1
Original file line numberDiff line numberDiff line change
@@ -2193,7 +2193,7 @@ Metadata and options are set in the config sections of the same name.
21932193
* In some cases, complex values can be provided in dedicated subsections for
21942194
clarity.
21952195

2196-
* Some keys allow ``file:``, ``attr:``, and ``find:`` and ``find_namespace:`` directives in
2196+
* Some keys allow ``file:``, ``attr:``, ``find:``, and ``find_namespace:`` directives in
21972197
order to cover common usecases.
21982198

21992199
* Unknown keys are ignored.
@@ -2290,6 +2290,12 @@ Special directives:
22902290

22912291
* ``attr:`` - Value is read from a module attribute. ``attr:`` supports
22922292
callables and iterables; unsupported types are cast using ``str()``.
2293+
2294+
In order to support the common case of a literal value assigned to a variable
2295+
in a module containing (directly or indirectly) third-party imports,
2296+
``attr:`` first tries to read the value from the module by examining the
2297+
module's AST. If that fails, ``attr:`` falls back to importing the module.
2298+
22932299
* ``file:`` - Value is read from a list of files and then concatenated
22942300

22952301

setuptools/config.py

+49-8
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
from __future__ import absolute_import, unicode_literals
2+
import ast
23
import io
34
import os
45
import sys
56

67
import warnings
78
import functools
9+
import importlib
810
from collections import defaultdict
911
from functools import partial
1012
from functools import wraps
11-
from importlib import import_module
13+
import contextlib
1214

1315
from distutils.errors import DistutilsOptionError, DistutilsFileError
1416
from setuptools.extern.packaging.version import LegacyVersion, parse
@@ -19,6 +21,44 @@
1921
__metaclass__ = type
2022

2123

24+
class StaticModule:
25+
"""
26+
Attempt to load the module by the name
27+
"""
28+
def __init__(self, name):
29+
spec = importlib.util.find_spec(name)
30+
with open(spec.origin) as strm:
31+
src = strm.read()
32+
module = ast.parse(src)
33+
vars(self).update(locals())
34+
del self.self
35+
36+
def __getattr__(self, attr):
37+
try:
38+
return next(
39+
ast.literal_eval(statement.value)
40+
for statement in self.module.body
41+
if isinstance(statement, ast.Assign)
42+
for target in statement.targets
43+
if isinstance(target, ast.Name) and target.id == attr
44+
)
45+
except Exception:
46+
raise AttributeError(
47+
"{self.name} has no attribute {attr}".format(**locals()))
48+
49+
50+
@contextlib.contextmanager
51+
def patch_path(path):
52+
"""
53+
Add path to front of sys.path for the duration of the context.
54+
"""
55+
try:
56+
sys.path.insert(0, path)
57+
yield
58+
finally:
59+
sys.path.remove(path)
60+
61+
2262
def read_configuration(
2363
filepath, find_others=False, ignore_option_errors=False):
2464
"""Read given configuration file and returns options from it as a dict.
@@ -344,15 +384,16 @@ def _parse_attr(cls, value, package_dir=None):
344384
elif '' in package_dir:
345385
# A custom parent directory was specified for all root modules
346386
parent_path = os.path.join(os.getcwd(), package_dir[''])
347-
sys.path.insert(0, parent_path)
348-
try:
349-
module = import_module(module_name)
350-
value = getattr(module, attr_name)
351387

352-
finally:
353-
sys.path = sys.path[1:]
388+
with patch_path(parent_path):
389+
try:
390+
# attempt to load value statically
391+
return getattr(StaticModule(module_name), attr_name)
392+
except Exception:
393+
# fallback to simple import
394+
module = importlib.import_module(module_name)
354395

355-
return value
396+
return getattr(module, attr_name)
356397

357398
@classmethod
358399
def _get_parser_compound(cls, *parse_methods):

setuptools/tests/test_config.py

+27-5
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
from __future__ import unicode_literals
33

44
import contextlib
5+
56
import pytest
67

78
from distutils.errors import DistutilsOptionError, DistutilsFileError
89
from mock import patch
910
from setuptools.dist import Distribution, _Distribution
1011
from setuptools.config import ConfigHandler, read_configuration
1112
from setuptools.extern.six.moves import configparser
13+
from setuptools.extern import six
1214
from . import py2_only, py3_only
1315
from .textwrap import DALS
1416

@@ -53,6 +55,7 @@ def fake_env(
5355
' return [3, 4, 5, "dev"]\n'
5456
'\n'
5557
)
58+
5659
return package_dir, config
5760

5861

@@ -267,11 +270,23 @@ def test_dict(self, tmpdir):
267270

268271
def test_version(self, tmpdir):
269272

270-
_, config = fake_env(
273+
package_dir, config = fake_env(
271274
tmpdir,
272275
'[metadata]\n'
273276
'version = attr: fake_package.VERSION\n'
274277
)
278+
279+
sub_a = package_dir.mkdir('subpkg_a')
280+
sub_a.join('__init__.py').write('')
281+
sub_a.join('mod.py').write('VERSION = (2016, 11, 26)')
282+
283+
sub_b = package_dir.mkdir('subpkg_b')
284+
sub_b.join('__init__.py').write('')
285+
sub_b.join('mod.py').write(
286+
'import third_party_module\n'
287+
'VERSION = (2016, 11, 26)'
288+
)
289+
275290
with get_dist(tmpdir) as dist:
276291
assert dist.metadata.version == '1.2.3'
277292

@@ -289,13 +304,20 @@ def test_version(self, tmpdir):
289304
with get_dist(tmpdir) as dist:
290305
assert dist.metadata.version == '1'
291306

292-
subpack = tmpdir.join('fake_package').mkdir('subpackage')
293-
subpack.join('__init__.py').write('')
294-
subpack.join('submodule.py').write('VERSION = (2016, 11, 26)')
307+
config.write(
308+
'[metadata]\n'
309+
'version = attr: fake_package.subpkg_a.mod.VERSION\n'
310+
)
311+
with get_dist(tmpdir) as dist:
312+
assert dist.metadata.version == '2016.11.26'
313+
314+
if six.PY2:
315+
# static version loading is unsupported on Python 2
316+
return
295317

296318
config.write(
297319
'[metadata]\n'
298-
'version = attr: fake_package.subpackage.submodule.VERSION\n'
320+
'version = attr: fake_package.subpkg_b.mod.VERSION\n'
299321
)
300322
with get_dist(tmpdir) as dist:
301323
assert dist.metadata.version == '2016.11.26'

0 commit comments

Comments
 (0)