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. ...@@ -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 * In some cases, complex values can be provided in dedicated subsections for
clarity. 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. order to cover common usecases.
* Unknown keys are ignored. * Unknown keys are ignored.
...@@ -2290,6 +2290,12 @@ Special directives: ...@@ -2290,6 +2290,12 @@ Special directives:
* ``attr:`` - Value is read from a module attribute. ``attr:`` supports * ``attr:`` - Value is read from a module attribute. ``attr:`` supports
callables and iterables; unsupported types are cast using ``str()``. 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 * ``file:`` - Value is read from a list of files and then concatenated
......
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import ast
import io import io
import os import os
import sys import sys
import warnings import warnings
import functools import functools
import importlib
from collections import defaultdict from collections import defaultdict
from functools import partial from functools import partial
from functools import wraps from functools import wraps
from importlib import import_module import contextlib
from distutils.errors import DistutilsOptionError, DistutilsFileError from distutils.errors import DistutilsOptionError, DistutilsFileError
from setuptools.extern.packaging.version import LegacyVersion, parse from setuptools.extern.packaging.version import LegacyVersion, parse
...@@ -19,6 +21,44 @@ from setuptools.extern.six import string_types, PY3 ...@@ -19,6 +21,44 @@ from setuptools.extern.six import string_types, PY3
__metaclass__ = type __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( def read_configuration(
filepath, find_others=False, ignore_option_errors=False): filepath, find_others=False, ignore_option_errors=False):
"""Read given configuration file and returns options from it as a dict. """Read given configuration file and returns options from it as a dict.
...@@ -344,15 +384,16 @@ class ConfigHandler: ...@@ -344,15 +384,16 @@ class ConfigHandler:
elif '' in package_dir: elif '' in package_dir:
# A custom parent directory was specified for all root modules # A custom parent directory was specified for all root modules
parent_path = os.path.join(os.getcwd(), package_dir['']) 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: with patch_path(parent_path):
sys.path = sys.path[1:] 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 @classmethod
def _get_parser_compound(cls, *parse_methods): def _get_parser_compound(cls, *parse_methods):
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import contextlib import contextlib
import pytest import pytest
from distutils.errors import DistutilsOptionError, DistutilsFileError from distutils.errors import DistutilsOptionError, DistutilsFileError
...@@ -9,6 +10,7 @@ from mock import patch ...@@ -9,6 +10,7 @@ from mock import patch
from setuptools.dist import Distribution, _Distribution from setuptools.dist import Distribution, _Distribution
from setuptools.config import ConfigHandler, read_configuration from setuptools.config import ConfigHandler, read_configuration
from setuptools.extern.six.moves import configparser from setuptools.extern.six.moves import configparser
from setuptools.extern import six
from . import py2_only, py3_only from . import py2_only, py3_only
from .textwrap import DALS from .textwrap import DALS
...@@ -53,6 +55,7 @@ def fake_env( ...@@ -53,6 +55,7 @@ def fake_env(
' return [3, 4, 5, "dev"]\n' ' return [3, 4, 5, "dev"]\n'
'\n' '\n'
) )
return package_dir, config return package_dir, config
...@@ -267,11 +270,23 @@ class TestMetadata: ...@@ -267,11 +270,23 @@ class TestMetadata:
def test_version(self, tmpdir): def test_version(self, tmpdir):
_, config = fake_env( package_dir, config = fake_env(
tmpdir, tmpdir,
'[metadata]\n' '[metadata]\n'
'version = attr: fake_package.VERSION\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: with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1.2.3' assert dist.metadata.version == '1.2.3'
...@@ -289,13 +304,20 @@ class TestMetadata: ...@@ -289,13 +304,20 @@ class TestMetadata:
with get_dist(tmpdir) as dist: with get_dist(tmpdir) as dist:
assert dist.metadata.version == '1' assert dist.metadata.version == '1'
subpack = tmpdir.join('fake_package').mkdir('subpackage') config.write(
subpack.join('__init__.py').write('') '[metadata]\n'
subpack.join('submodule.py').write('VERSION = (2016, 11, 26)') '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( config.write(
'[metadata]\n' '[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: with get_dist(tmpdir) as dist:
assert dist.metadata.version == '2016.11.26' 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