Commit c457e683 authored by Jason R. Coombs's avatar Jason R. Coombs Committed by GitHub

Merge pull request #1753 from jwodder/feature/literal_attr

Support attr directive with static evaluation
parents f9364aaa 39a37c07
``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.
......@@ -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
......
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):
......
......@@ -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'
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment