Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support attr directive with static evaluation #1753

Merged
merged 12 commits into from
May 17, 2020
4 changes: 4 additions & 0 deletions changelog.d/1753.change.rst
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion docs/setuptools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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


Expand Down
57 changes: 49 additions & 8 deletions setuptools/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,6 +21,44 @@
__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.
Expand Down Expand Up @@ -344,15 +384,16 @@ def _parse_attr(cls, value, package_dir=None):
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):
Expand Down
32 changes: 27 additions & 5 deletions setuptools/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
from __future__ import unicode_literals

import contextlib

import pytest

from distutils.errors import DistutilsOptionError, DistutilsFileError
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

Expand Down Expand Up @@ -53,6 +55,7 @@ def fake_env(
' return [3, 4, 5, "dev"]\n'
'\n'
)

return package_dir, config


Expand Down Expand Up @@ -267,11 +270,23 @@ def test_dict(self, tmpdir):

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'

Expand All @@ -289,13 +304,20 @@ def test_version(self, tmpdir):
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'
Expand Down