Repository: pypa/setuptools
Pull request #862 (2016-11-26 15:13:09)

Metadata and options are now could be set in configuration files
Hi,

It is useful to be able to have metadata and other options declared in configuration files (e.g. `setup.cfg`). That not only allows some automation, but also reduces bolerplate code in some cases.
See also #394. 

This chage allows us to have something like this in our config files:
```
[metadata]
name = some_name
version = attr: some_package.VERSION
description = Some description
long_description = file: README
keywords = one, two
license = BSD 3-Clause License

[metadata.classifiers]
Framework :: Django
Programming Language :: Python :: 3.5

[options]
zip_safe = False
include_package_data = True
packages = find:
scripts = 
  bin/one.py
  bin/two.py

[options.package_data]
* = *.txt, *.rst
hello = *.msg

[options.extras_require]
pdf = ReportLab>=1.2; RXP
rest = docutils>=0.3; pack ==1.1, ==1.3
```

Currently every metadata and other options are supported (or I'd like to believe that). More examples in `test_config.py`.
What I'm concerned with though is whether we need to sandbox `file:` directive which is supported by some options for security considerations (shouldn't it be impossible to read data from outside).

_If overall design and implementation is fine I could update the documentation._

Below are supported ways of value definition for various options.

### Terms used
- **list-comma**: dangling list or comma-separated values string
- **list-semi**:  dangling list or semicolon-separated values string
- **bool**: `True` is `1`, `yes`, `true`
- **dict**: list-comma where keys from values are separated by =
- **attr**: value could be read from module attibute
- **file**: value could be read from a file
- **section**: values could be read from a dedicated section

### Metadata
- version: string or attr (iterables, callables are supported; others - through `str()`)
- platforms (alias: platform): list-comma
- keywords: list-comma
- provides: list-comma
- requires: list-comma
- obsoletes: list-comma
- classifiers (alias: classifier): file, section, or list-comma
- license: file or string
- description (alias: summary): file or string
- long_description: file or string
- author_email (alias: author-email): string
- url (alias: home-page): string

### Options
- zip_safe: bool
- setup_requires: list-semi
- install_requires: list-semi
- extras_require: section
- entry_points: file, section
- use_2to3: bool
- use_2to3_fixers: list-comma
- use_2to3_exclude_fixers: list-comma
- convert_2to3_doctests: list-comma
- scripts: list-comma
- eager_resources: list-comma
- dependency_links: list-comma
- tests_require: list-semi
- include_package_data: bool
- packages: list-comma or find:
- package_dir: dict
- package_data: section
- exclude_package_data: section
- namespace_packages: list-comma



Patch:
diff --git a/docs/setuptools.txt b/docs/setuptools.txt
index 5ce2c7b1e..77de255b9 100644
--- a/docs/setuptools.txt
+++ b/docs/setuptools.txt
@@ -2398,6 +2398,191 @@ The ``upload_docs`` command has the following options:
     https://pypi.python.org/pypi (i.e., the main PyPI installation).
 
 
+-----------------------------------------
+Configuring setup() using setup.cfg files
+-----------------------------------------
+
+``Setuptools`` allows using configuration files (usually `setup.cfg`)
+to define package’s metadata and other options which are normally supplied
+to ``setup()`` function.
+
+This approach not only allows automation scenarios, but also reduces
+boilerplate code in some cases.
+
+.. note::
+    Implementation presents limited compatibility with distutils2-like
+    ``setup.cfg`` sections (used by ``pbr`` and ``d2to1`` packages).
+
+    Namely: only metadata related keys from ``metadata`` section are supported
+    (except for ``description-file``); keys from ``files``, ``entry_points``
+    and ``backwards_compat`` are not supported.
+
+
+.. code-block:: ini
+
+    [metadata]
+    name = my_package
+    version = attr: src.VERSION
+    description = My package description
+    long_description = file: README.rst
+    keywords = one, two
+    license = BSD 3-Clause License
+
+    [metadata.classifiers]
+    Framework :: Django
+    Programming Language :: Python :: 3.5
+
+    [options]
+    zip_safe = False
+    include_package_data = True
+    packages = find:
+    scripts =
+      bin/first.py
+      bin/second.py
+
+    [options.package_data]
+    * = *.txt, *.rst
+    hello = *.msg
+
+    [options.extras_require]
+    pdf = ReportLab>=1.2; RXP
+    rest = docutils>=0.3; pack ==1.1, ==1.3
+
+
+Metadata and options could be set in sections with the same names.
+
+* Keys are the same as keyword arguments one provides to ``setup()`` function.
+
+* Complex values could be placed comma-separated or one per line
+  in *dangling* sections. The following are the same:
+
+  .. code-block:: ini
+
+      [metadata]
+      keywords = one, two
+
+      [metadata]
+      keywords =
+        one
+        two
+
+* In some cases complex values could be provided in subsections for clarity.
+
+* Some keys allow ``file:``, ``attr:`` and ``find:`` directives to cover
+  common usecases.
+
+* Unknown keys are ignored.
+
+
+Specifying values
+=================
+
+Some values are treated as simple strings, some allow more logic.
+
+Type names used below:
+
+* ``str`` - simple string
+* ``list-comma`` - dangling list or comma-separated values string
+* ``list-semi`` - dangling list or semicolon-separated values string
+* ``bool`` -  ``True`` is 1, yes, true
+* ``dict`` - list-comma where keys from values are separated by =
+
+
+Special directives:
+
+* ``attr:`` - value could be read from module attribute
+* ``file:`` - value could be read from a file
+* ``section:`` - values could be read from a dedicated (sub)section
+
+
+.. note::
+    ``file:`` directive is sandboxed and won't reach anything outside
+    directory with ``setup.py``.
+
+
+Metadata
+--------
+
+.. note::
+    Aliases given below are supported for compatibility reasons,
+    but not advised.
+
+=================  =================  =====
+Key                Aliases            Accepted value type
+=================  =================  =====
+name                                  str
+version                               attr:, str
+url                home-page          str
+download_url       download-url       str
+author                                str
+author_email       author-email       str
+maintainer                            str
+maintainer_email   maintainer-email   str
+classifiers        classifier         file:, section, list-comma
+license                               file:, str
+description        summary            file:, str
+long_description   long-description   file:, str
+keywords                              list-comma
+platforms          platform           list-comma
+provides                              list-comma
+requires                              list-comma
+obsoletes                             list-comma
+=================  =================  =====
+
+**version** - ``attr:`` supports callables; supports iterables;
+unsupported types are casted using ``str()``.
+
+
+Options
+-------
+
+=======================  =====
+Key                      Accepted value type
+=======================  =====
+zip_safe                 bool
+setup_requires           list-semi
+install_requires         list-semi
+extras_require           section
+entry_points             file, section
+use_2to3                 bool
+use_2to3_fixers          list-comma
+use_2to3_exclude_fixers  list-comma
+convert_2to3_doctests    list-comma
+scripts                  list-comma
+eager_resources          list-comma
+dependency_links         list-comma
+tests_require            list-semi
+include_package_data     bool
+packages                 find:, list-comma
+package_dir              dict
+package_data             section
+exclude_package_data     section
+namespace_packages       list-comma
+=======================  =====
+
+
+Configuration API
+=================
+
+Some automation tools may wish to access data from a configuration file.
+
+``Setuptools`` exposes ``read_configuration()`` function allowing
+parsing ``metadata`` and ``options`` sections into a dictionary.
+
+
+.. code-block:: python
+
+    from setuptools.config import read_configuration
+
+    conf_dict = read_configuration('/home/user/dev/package/setup.cfg')
+
+
+By default ``read_configuration()`` will read only file provided
+in the first argument. To include values from other configuration files
+which could be in various places set `find_others` function argument
+to ``True``.
+
+
 --------------------------------
 Extending and Reusing Setuptools
 --------------------------------
diff --git a/setuptools/config.py b/setuptools/config.py
new file mode 100644
index 000000000..889dc683d
--- /dev/null
+++ b/setuptools/config.py
@@ -0,0 +1,501 @@
+from __future__ import absolute_import, unicode_literals
+import io
+import os
+import sys
+from collections import defaultdict
+from functools import partial
+
+from distutils.errors import DistutilsOptionError, DistutilsFileError
+from setuptools.py26compat import import_module
+from setuptools.extern.six import string_types
+
+
+def read_configuration(filepath, find_others=False):
+    """Read given configuration file and returns options from it as a dict.
+
+    :param str|unicode filepath: Path to configuration file
+        to get options from.
+
+    :param bool find_others: Whether to search for other configuration files
+        which could be on in various places.
+
+    :rtype: dict
+    """
+    from setuptools.dist import Distribution, _Distribution
+
+    filepath = os.path.abspath(filepath)
+
+    if not os.path.isfile(filepath):
+        raise DistutilsFileError(
+            'Configuration file %s does not exist.' % filepath)
+
+    current_directory = os.getcwd()
+    os.chdir(os.path.dirname(filepath))
+
+    dist = Distribution()
+
+    filenames = dist.find_config_files() if find_others else []
+    if filepath not in filenames:
+        filenames.append(filepath)
+
+    _Distribution.parse_config_files(dist, filenames=filenames)
+
+    handlers = parse_configuration(dist, dist.command_options)
+
+    os.chdir(current_directory)
+
+    return configuration_to_dict(handlers)
+
+
+def configuration_to_dict(handlers):
+    """Returns configuration data gathered by given handlers as a dict.
+
+    :param list[ConfigHandler] handlers: Handlers list,
+        usually from parse_configuration()
+
+    :rtype: dict
+    """
+    config_dict = defaultdict(dict)
+
+    for handler in handlers:
+
+        obj_alias = handler.section_prefix
+        target_obj = handler.target_obj
+
+        for option in handler.set_options:
+            getter = getattr(target_obj, 'get_%s' % option, None)
+
+            if getter is None:
+                value = getattr(target_obj, option)
+
+            else:
+                value = getter()
+
+            config_dict[obj_alias][option] = value
+
+    return config_dict
+
+
+def parse_configuration(distribution, command_options):
+    """Performs additional parsing of configuration options
+    for a distribution.
+
+    Returns a list of used option handlers.
+
+    :param Distribution distribution:
+    :param dict command_options:
+    :rtype: list
+    """
+    meta = ConfigMetadataHandler(distribution.metadata, command_options)
+    meta.parse()
+
+    options = ConfigOptionsHandler(distribution, command_options)
+    options.parse()
+
+    return [meta, options]
+
+
+class ConfigHandler(object):
+    """Handles metadata supplied in configuration files."""
+
+    section_prefix = None
+    """Prefix for config sections handled by this handler.
+    Must be provided by class heirs.
+
+    """
+
+    aliases = {}
+    """Options aliases.
+    For compatibility with various packages. E.g.: d2to1 and pbr.
+    Note: `-` in keys is replaced with `_` by config parser.
+
+    """
+
+    def __init__(self, target_obj, options):
+        sections = {}
+
+        section_prefix = self.section_prefix
+        for section_name, section_options in options.items():
+            if not section_name.startswith(section_prefix):
+                continue
+
+            section_name = section_name.replace(section_prefix, '').strip('.')
+            sections[section_name] = section_options
+
+        self.target_obj = target_obj
+        self.sections = sections
+        self.set_options = []
+
+    @property
+    def parsers(self):
+        """Metadata item name to parser function mapping."""
+        raise NotImplementedError(
+            '%s must provide .parsers property' % self.__class__.__name__)
+
+    def __setitem__(self, option_name, value):
+        unknown = tuple()
+        target_obj = self.target_obj
+
+        # Translate alias into real name.
+        option_name = self.aliases.get(option_name, option_name)
+
+        current_value = getattr(target_obj, option_name, unknown)
+
+        if current_value is unknown:
+            raise KeyError(option_name)
+
+        if current_value:
+            # Already inhabited. Skipping.
+            return
+
+        parser = self.parsers.get(option_name)
+        if parser:
+            value = parser(value)
+
+        setter = getattr(target_obj, 'set_%s' % option_name, None)
+        if setter is None:
+            setattr(target_obj, option_name, value)
+        else:
+            setter(value)
+
+        self.set_options.append(option_name)
+
+    @classmethod
+    def _parse_list(cls, value, separator=','):
+        """Represents value as a list.
+
+        Value is split either by separator (defaults to comma) or by lines.
+
+        :param value:
+        :param separator: List items separator character.
+        :rtype: list
+        """
+        if isinstance(value, list):  # _get_parser_compound case
+            return value
+
+        if '\n' in value:
+            value = value.splitlines()
+        else:
+            value = value.split(separator)
+
+        return [chunk.strip() for chunk in value if chunk.strip()]
+
+    @classmethod
+    def _parse_dict(cls, value):
+        """Represents value as a dict.
+
+        :param value:
+        :rtype: dict
+        """
+        separator = '='
+        result = {}
+        for line in cls._parse_list(value):
+            key, sep, val = line.partition(separator)
+            if sep != separator:
+                raise DistutilsOptionError(
+                    'Unable to parse option value to dict: %s' % value)
+            result[key.strip()] = val.strip()
+
+        return result
+
+    @classmethod
+    def _parse_bool(cls, value):
+        """Represents value as boolean.
+
+        :param value:
+        :rtype: bool
+        """
+        value = value.lower()
+        return value in ('1', 'true', 'yes')
+
+    @classmethod
+    def _parse_file(cls, value):
+        """Represents value as a string, allowing including text
+        from nearest files using `file:` directive.
+
+        Directive is sandboxed and won't reach anything outside
+        directory with setup.py.
+
+        Examples:
+            include: LICENSE
+            include: src/file.txt
+
+        :param str value:
+        :rtype: str
+        """
+        if not isinstance(value, string_types):
+            return value
+
+        include_directive = 'file:'
+        if not value.startswith(include_directive):
+            return value
+
+        current_directory = os.getcwd()
+
+        filepath = value.replace(include_directive, '').strip()
+        filepath = os.path.abspath(filepath)
+
+        if not filepath.startswith(current_directory):
+            raise DistutilsOptionError(
+                '`file:` directive can not access %s' % filepath)
+
+        if os.path.isfile(filepath):
+            with io.open(filepath, encoding='utf-8') as f:
+                value = f.read()
+
+        return value
+
+    @classmethod
+    def _parse_attr(cls, value):
+        """Represents value as a module attribute.
+
+        Examples:
+            attr: package.attr
+            attr: package.module.attr
+
+        :param str value:
+        :rtype: str
+        """
+        attr_directive = 'attr:'
+        if not value.startswith(attr_directive):
+            return value
+
+        attrs_path = value.replace(attr_directive, '').strip().split('.')
+        attr_name = attrs_path.pop()
+
+        module_name = '.'.join(attrs_path)
+        module_name = module_name or '__init__'
+
+        sys.path.insert(0, os.getcwd())
+        try:
+            module = import_module(module_name)
+            value = getattr(module, attr_name)
+
+        finally:
+            sys.path = sys.path[1:]
+
+        return value
+
+    @classmethod
+    def _get_parser_compound(cls, *parse_methods):
+        """Returns parser function to represents value as a list.
+
+        Parses a value applying given methods one after another.
+
+        :param parse_methods:
+        :rtype: callable
+        """
+        def parse(value):
+            parsed = value
+
+            for method in parse_methods:
+                parsed = method(parsed)
+
+            return parsed
+
+        return parse
+
+    @classmethod
+    def _parse_section_to_dict(cls, section_options, values_parser=None):
+        """Parses section options into a dictionary.
+
+        Optionally applies a given parser to values.
+
+        :param dict section_options:
+        :param callable values_parser:
+        :rtype: dict
+        """
+        value = {}
+        values_parser = values_parser or (lambda val: val)
+        for key, (_, val) in section_options.items():
+            value[key] = values_parser(val)
+        return value
+
+    def parse_section(self, section_options):
+        """Parses configuration file section.
+
+        :param dict section_options:
+        """
+        for (name, (_, value)) in section_options.items():
+            try:
+                self[name] = value
+
+            except KeyError:
+                pass  # Keep silent for a new option may appear anytime.
+
+    def parse(self):
+        """Parses configuration file items from one
+        or more related sections.
+
+        """
+        for section_name, section_options in self.sections.items():
+
+            method_postfix = ''
+            if section_name:  # [section.option] variant
+                method_postfix = '_%s' % section_name
+
+            section_parser_method = getattr(
+                self, 'parse_section%s' % method_postfix, None)
+
+            if section_parser_method is None:
+                raise DistutilsOptionError(
+                    'Unsupported distribution option section: [%s.%s]' % (
+                        self.section_prefix, section_name))
+
+            section_parser_method(section_options)
+
+
+class ConfigMetadataHandler(ConfigHandler):
+
+    section_prefix = 'metadata'
+
+    aliases = {
+        'home_page': 'url',
+        'summary': 'description',
+        'classifier': 'classifiers',
+        'platform': 'platforms',
+    }
+
+    strict_mode = False
+    """We need to keep it loose, to be partially compatible with
+    `pbr` and `d2to1` packages which also uses `metadata` section.
+
+    """
+
+    @property
+    def parsers(self):
+        """Metadata item name to parser function mapping."""
+        parse_list = self._parse_list
+        parse_file = self._parse_file
+
+        return {
+            'platforms': parse_list,
+            'keywords': parse_list,
+            'provides': parse_list,
+            'requires': parse_list,
+            'obsoletes': parse_list,
+            'classifiers': self._get_parser_compound(parse_file, parse_list),
+            'license': parse_file,
+            'description': parse_file,
+            'long_description': parse_file,
+            'version': self._parse_version,
+        }
+
+    def parse_section_classifiers(self, section_options):
+        """Parses configuration file section.
+
+        :param dict section_options:
+        """
+        classifiers = []
+        for begin, (_, rest) in section_options.items():
+            classifiers.append('%s :%s' % (begin.title(), rest))
+
+        self['classifiers'] = classifiers
+
+    def _parse_version(self, value):
+        """Parses `version` option value.
+
+        :param value:
+        :rtype: str
+
+        """
+        version = self._parse_attr(value)
+
+        if callable(version):
+            version = version()
+
+        if not isinstance(version, string_types):
+            if hasattr(version, '__iter__'):
+                version = '.'.join(map(str, version))
+            else:
+                version = '%s' % version
+
+        return version
+
+
+class ConfigOptionsHandler(ConfigHandler):
+
+    section_prefix = 'options'
+
+    @property
+    def parsers(self):
+        """Metadata item name to parser function mapping."""
+        parse_list = self._parse_list
+        parse_list_semicolon = partial(self._parse_list, separator=';')
+        parse_bool = self._parse_bool
+        parse_dict = self._parse_dict
+
+        return {
+            'zip_safe': parse_bool,
+            'use_2to3': parse_bool,
+            'include_package_data': parse_bool,
+            'package_dir': parse_dict,
+            'use_2to3_fixers': parse_list,
+            'use_2to3_exclude_fixers': parse_list,
+            'convert_2to3_doctests': parse_list,
+            'scripts': parse_list,
+            'eager_resources': parse_list,
+            'dependency_links': parse_list,
+            'namespace_packages': parse_list,
+            'install_requires': parse_list_semicolon,
+            'setup_requires': parse_list_semicolon,
+            'tests_require': parse_list_semicolon,
+            'packages': self._parse_packages,
+            'entry_points': self._parse_file,
+        }
+
+    def _parse_packages(self, value):
+        """Parses `packages` option value.
+
+        :param value:
+        :rtype: list
+        """
+        find_directive = 'find:'
+
+        if not value.startswith(find_directive):
+            return self._parse_list(value)
+
+        from setuptools import find_packages
+        return find_packages()
+
+    def parse_section_entry_points(self, section_options):
+        """Parses `entry_points` configuration file section.
+
+        :param dict section_options:
+        """
+        parsed = self._parse_section_to_dict(section_options, self._parse_list)
+        self['entry_points'] = parsed
+
+    def _parse_package_data(self, section_options):
+        parsed = self._parse_section_to_dict(section_options, self._parse_list)
+
+        root = parsed.get('*')
+        if root:
+            parsed[''] = root
+            del parsed['*']
+
+        return parsed
+
+    def parse_section_package_data(self, section_options):
+        """Parses `package_data` configuration file section.
+
+        :param dict section_options:
+        """
+        self['package_data'] = self._parse_package_data(section_options)
+
+    def parse_section_exclude_package_data(self, section_options):
+        """Parses `exclude_package_data` configuration file section.
+
+        :param dict section_options:
+        """
+        self['exclude_package_data'] = self._parse_package_data(
+            section_options)
+
+    def parse_section_extras_require(self, section_options):
+        """Parses `extras_require` configuration file section.
+
+        :param dict section_options:
+        """
+        parse_list = partial(self._parse_list, separator=';')
+        self['extras_require'] = self._parse_section_to_dict(
+            section_options, parse_list)
diff --git a/setuptools/dist.py b/setuptools/dist.py
index 612040c85..c04e64268 100644
--- a/setuptools/dist.py
+++ b/setuptools/dist.py
@@ -19,6 +19,7 @@ from pkg_resources.extern import packaging
 from setuptools.depends import Require
 from setuptools import windows_support
 from setuptools.monkey import get_unpatched
+from setuptools.config import parse_configuration
 import pkg_resources
 
 
@@ -342,6 +343,15 @@ class Distribution(_Distribution):
         if getattr(self, 'python_requires', None):
             self.metadata.python_requires = self.python_requires
 
+    def parse_config_files(self, filenames=None):
+        """Parses configuration files from various levels
+        and loads configuration.
+
+        """
+        _Distribution.parse_config_files(self, filenames=filenames)
+
+        parse_configuration(self, self.command_options)
+
     def parse_command_line(self):
         """Process features after parsing command line options"""
         result = _Distribution.parse_command_line(self)


Test patch:
diff --git a/setuptools/tests/test_config.py b/setuptools/tests/test_config.py
new file mode 100644
index 000000000..214877209
--- /dev/null
+++ b/setuptools/tests/test_config.py
@@ -0,0 +1,472 @@
+import contextlib
+import pytest
+from distutils.errors import DistutilsOptionError, DistutilsFileError
+from setuptools.dist import Distribution
+from setuptools.config import ConfigHandler, read_configuration
+
+
+class ErrConfigHandler(ConfigHandler):
+    """Erroneous handler. Fails to implement required methods."""
+
+
+def fake_env(tmpdir, setup_cfg, setup_py=None):
+
+    if setup_py is None:
+        setup_py = (
+            'from setuptools import setup\n'
+            'setup()\n'
+        )
+
+    tmpdir.join('setup.py').write(setup_py)
+    tmpdir.join('setup.cfg').write(setup_cfg)
+
+    package_name = 'fake_package'
+    dir_package = tmpdir.mkdir(package_name)
+    dir_package.join('__init__.py').write(
+        'VERSION = (1, 2, 3)\n'
+        '\n'
+        'VERSION_MAJOR = 1'
+        '\n'
+        'def get_version():\n'
+        '    return [3, 4, 5, "dev"]\n'
+        '\n'
+    )
+
+
+@contextlib.contextmanager
+def get_dist(tmpdir, kwargs_initial=None, parse=True):
+    kwargs_initial = kwargs_initial or {}
+
+    with tmpdir.as_cwd():
+        dist = Distribution(kwargs_initial)
+        dist.script_name = 'setup.py'
+        parse and dist.parse_config_files()
+
+        yield dist
+
+
+def test_parsers_implemented():
+
+    with pytest.raises(NotImplementedError):
+        handler = ErrConfigHandler(None, {})
+        handler.parsers
+
+
+class TestConfigurationReader:
+
+    def test_basic(self, tmpdir):
+        fake_env(
+            tmpdir,
+            '[metadata]\n'
+            'version = 10.1.1\n'
+            'keywords = one, two\n'
+            '\n'
+            '[options]\n'
+            'scripts = bin/a.py, bin/b.py\n'
+        )
+        config_dict = read_configuration('%s' % tmpdir.join('setup.cfg'))
+        assert config_dict['metadata']['version'] == '10.1.1'
+        assert config_dict['metadata']['keywords'] == ['one', 'two']
+        assert config_dict['options']['scripts'] == ['bin/a.py', 'bin/b.py']
+
+    def test_no_config(self, tmpdir):
+        with pytest.raises(DistutilsFileError):
+            read_configuration('%s' % tmpdir.join('setup.cfg'))
+
+
+class TestMetadata:
+
+    def test_basic(self, tmpdir):
+
+        fake_env(
+            tmpdir,
+            '[metadata]\n'
+            'version = 10.1.1\n'
+            'description = Some description\n'
+            'long_description = file: README\n'
+            'name = fake_name\n'
+            'keywords = one, two\n'
+            'provides = package, package.sub\n'
+            'license = otherlic\n'
+            'download_url = http://test.test.com/test/\n'
+            'maintainer_email = test@test.com\n'
+        )
+
+        tmpdir.join('README').write('readme contents\nline2')
+
+        meta_initial = {
+            # This will be used so `otherlic` won't replace it.
+            'license': 'BSD 3-Clause License',
+        }
+
+        with get_dist(tmpdir, meta_initial) as dist:
+            metadata = dist.metadata
+
+            assert metadata.version == '10.1.1'
+            assert metadata.description == 'Some description'
+            assert metadata.long_description == 'readme contents\nline2'
+            assert metadata.provides == ['package', 'package.sub']
+            assert metadata.license == 'BSD 3-Clause License'
+            assert metadata.name == 'fake_name'
+            assert metadata.keywords == ['one', 'two']
+            assert metadata.download_url == 'http://test.test.com/test/'
+            assert metadata.maintainer_email == 'test@test.com'
+
+    def test_file_sandboxed(self, tmpdir):
+
+        fake_env(
+            tmpdir,
+            '[metadata]\n'
+            'long_description = file: ../../README\n'
+        )
+
+        with get_dist(tmpdir, parse=False) as dist:
+            with pytest.raises(DistutilsOptionError):
+                dist.parse_config_files()  # file: out of sandbox
+
+    def test_aliases(self, tmpdir):
+
+        fake_env(
+            tmpdir,
+            '[metadata]\n'
+            'author-email = test@test.com\n'
+            'home-page = http://test.test.com/test/\n'
+            'summary = Short summary\n'
+            'platform = a, b\n'
+            'classifier =\n'
+            '  Framework :: Django\n'
+            '  Programming Language :: Python :: 3.5\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            metadata = dist.metadata
+            assert metadata.author_email == 'test@test.com'
+            assert metadata.url == 'http://test.test.com/test/'
+            assert metadata.description == 'Short summary'
+            assert metadata.platforms == ['a', 'b']
+            assert metadata.classifiers == [
+                'Framework :: Django',
+                'Programming Language :: Python :: 3.5',
+            ]
+
+    def test_multiline(self, tmpdir):
+
+        fake_env(
+            tmpdir,
+            '[metadata]\n'
+            'name = fake_name\n'
+            'keywords =\n'
+            '  one\n'
+            '  two\n'
+            'classifiers =\n'
+            '  Framework :: Django\n'
+            '  Programming Language :: Python :: 3.5\n'
+        )
+        with get_dist(tmpdir) as dist:
+            metadata = dist.metadata
+            assert metadata.keywords == ['one', 'two']
+            assert metadata.classifiers == [
+                'Framework :: Django',
+                'Programming Language :: Python :: 3.5',
+            ]
+
+    def test_version(self, tmpdir):
+
+        fake_env(
+            tmpdir,
+            '[metadata]\n'
+            'version = attr: fake_package.VERSION\n'
+        )
+        with get_dist(tmpdir) as dist:
+            assert dist.metadata.version == '1.2.3'
+
+        tmpdir.join('setup.cfg').write(
+            '[metadata]\n'
+            'version = attr: fake_package.get_version\n'
+        )
+        with get_dist(tmpdir) as dist:
+            assert dist.metadata.version == '3.4.5.dev'
+
+        tmpdir.join('setup.cfg').write(
+            '[metadata]\n'
+            'version = attr: fake_package.VERSION_MAJOR\n'
+        )
+        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)')
+
+        tmpdir.join('setup.cfg').write(
+            '[metadata]\n'
+            'version = attr: fake_package.subpackage.submodule.VERSION\n'
+        )
+        with get_dist(tmpdir) as dist:
+            assert dist.metadata.version == '2016.11.26'
+
+    def test_unknown_meta_item(self, tmpdir):
+
+        fake_env(
+            tmpdir,
+            '[metadata]\n'
+            'name = fake_name\n'
+            'unknown = some\n'
+        )
+        with get_dist(tmpdir, parse=False) as dist:
+            dist.parse_config_files()  # Skip unknown.
+
+    def test_usupported_section(self, tmpdir):
+
+        fake_env(
+            tmpdir,
+            '[metadata.some]\n'
+            'key = val\n'
+        )
+        with get_dist(tmpdir, parse=False) as dist:
+            with pytest.raises(DistutilsOptionError):
+                dist.parse_config_files()
+
+    def test_classifiers(self, tmpdir):
+        expected = set([
+            'Framework :: Django',
+            'Programming Language :: Python :: 3.5',
+        ])
+
+        # From file.
+        fake_env(
+            tmpdir,
+            '[metadata]\n'
+            'classifiers = file: classifiers\n'
+        )
+
+        tmpdir.join('classifiers').write(
+            'Framework :: Django\n'
+            'Programming Language :: Python :: 3.5\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            assert set(dist.metadata.classifiers) == expected
+
+        # From section.
+        tmpdir.join('setup.cfg').write(
+            '[metadata.classifiers]\n'
+            'Framework :: Django\n'
+            'Programming Language :: Python :: 3.5\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            assert set(dist.metadata.classifiers) == expected
+
+
+class TestOptions:
+
+    def test_basic(self, tmpdir):
+
+        fake_env(
+            tmpdir,
+            '[options]\n'
+            'zip_safe = True\n'
+            'use_2to3 = 1\n'
+            'include_package_data = yes\n'
+            'package_dir = b=c, =src\n'
+            'packages = pack_a, pack_b.subpack\n'
+            'namespace_packages = pack1, pack2\n'
+            'use_2to3_fixers = your.fixers, or.here\n'
+            'use_2to3_exclude_fixers = one.here, two.there\n'
+            'convert_2to3_doctests = src/tests/one.txt, src/two.txt\n'
+            'scripts = bin/one.py, bin/two.py\n'
+            'eager_resources = bin/one.py, bin/two.py\n'
+            'install_requires = docutils>=0.3; pack ==1.1, ==1.3; hey\n'
+            'tests_require = mock==0.7.2; pytest\n'
+            'setup_requires = docutils>=0.3; spack ==1.1, ==1.3; there\n'
+            'dependency_links = http://some.com/here/1, '
+                'http://some.com/there/2\n'
+        )
+        with get_dist(tmpdir) as dist:
+            assert dist.zip_safe
+            assert dist.use_2to3
+            assert dist.include_package_data
+            assert dist.package_dir == {'': 'src', 'b': 'c'}
+            assert dist.packages == ['pack_a', 'pack_b.subpack']
+            assert dist.namespace_packages == ['pack1', 'pack2']
+            assert dist.use_2to3_fixers == ['your.fixers', 'or.here']
+            assert dist.use_2to3_exclude_fixers == ['one.here', 'two.there']
+            assert dist.convert_2to3_doctests == ([
+                'src/tests/one.txt', 'src/two.txt'])
+            assert dist.scripts == ['bin/one.py', 'bin/two.py']
+            assert dist.dependency_links == ([
+                'http://some.com/here/1',
+                'http://some.com/there/2'
+            ])
+            assert dist.install_requires == ([
+                'docutils>=0.3',
+                'pack ==1.1, ==1.3',
+                'hey'
+            ])
+            assert dist.setup_requires == ([
+                'docutils>=0.3',
+                'spack ==1.1, ==1.3',
+                'there'
+            ])
+            assert dist.tests_require == ['mock==0.7.2', 'pytest']
+
+    def test_multiline(self, tmpdir):
+        fake_env(
+            tmpdir,
+            '[options]\n'
+            'package_dir = \n'
+            '  b=c\n'
+            '  =src\n'
+            'packages = \n'
+            '  pack_a\n'
+            '  pack_b.subpack\n'
+            'namespace_packages = \n'
+            '  pack1\n'
+            '  pack2\n'
+            'use_2to3_fixers = \n'
+            '  your.fixers\n'
+            '  or.here\n'
+            'use_2to3_exclude_fixers = \n'
+            '  one.here\n'
+            '  two.there\n'
+            'convert_2to3_doctests = \n'
+            '  src/tests/one.txt\n'
+            '  src/two.txt\n'
+            'scripts = \n'
+            '  bin/one.py\n'
+            '  bin/two.py\n'
+            'eager_resources = \n'
+            '  bin/one.py\n'
+            '  bin/two.py\n'
+            'install_requires = \n'
+            '  docutils>=0.3\n'
+            '  pack ==1.1, ==1.3\n'
+            '  hey\n'
+            'tests_require = \n'
+            '  mock==0.7.2\n'
+            '  pytest\n'
+            'setup_requires = \n'
+            '  docutils>=0.3\n'
+            '  spack ==1.1, ==1.3\n'
+            '  there\n'
+            'dependency_links = \n'
+            '  http://some.com/here/1\n'
+            '  http://some.com/there/2\n'
+        )
+        with get_dist(tmpdir) as dist:
+            assert dist.package_dir == {'': 'src', 'b': 'c'}
+            assert dist.packages == ['pack_a', 'pack_b.subpack']
+            assert dist.namespace_packages == ['pack1', 'pack2']
+            assert dist.use_2to3_fixers == ['your.fixers', 'or.here']
+            assert dist.use_2to3_exclude_fixers == ['one.here', 'two.there']
+            assert dist.convert_2to3_doctests == (
+                ['src/tests/one.txt', 'src/two.txt'])
+            assert dist.scripts == ['bin/one.py', 'bin/two.py']
+            assert dist.dependency_links == ([
+                'http://some.com/here/1',
+                'http://some.com/there/2'
+            ])
+            assert dist.install_requires == ([
+                'docutils>=0.3',
+                'pack ==1.1, ==1.3',
+                'hey'
+            ])
+            assert dist.setup_requires == ([
+                'docutils>=0.3',
+                'spack ==1.1, ==1.3',
+                'there'
+            ])
+            assert dist.tests_require == ['mock==0.7.2', 'pytest']
+
+    def test_package_dir_fail(self, tmpdir):
+        fake_env(
+            tmpdir,
+            '[options]\n'
+            'package_dir = a b\n'
+        )
+        with get_dist(tmpdir, parse=False) as dist:
+            with pytest.raises(DistutilsOptionError):
+                dist.parse_config_files()
+
+    def test_package_data(self, tmpdir):
+        fake_env(
+            tmpdir,
+            '[options.package_data]\n'
+            '* = *.txt, *.rst\n'
+            'hello = *.msg\n'
+            '\n'
+            '[options.exclude_package_data]\n'
+            '* = fake1.txt, fake2.txt\n'
+            'hello = *.dat\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            assert dist.package_data == {
+                '': ['*.txt', '*.rst'],
+                'hello': ['*.msg'],
+            }
+            assert dist.exclude_package_data == {
+                '': ['fake1.txt', 'fake2.txt'],
+                'hello': ['*.dat'],
+            }
+
+    def test_packages(self, tmpdir):
+        fake_env(
+            tmpdir,
+            '[options]\n'
+            'packages = find:\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            assert dist.packages == ['fake_package']
+
+    def test_extras_require(self, tmpdir):
+        fake_env(
+            tmpdir,
+            '[options.extras_require]\n'
+            'pdf = ReportLab>=1.2; RXP\n'
+            'rest = \n'
+            '  docutils>=0.3\n'
+            '  pack ==1.1, ==1.3\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            assert dist.extras_require == {
+                'pdf': ['ReportLab>=1.2', 'RXP'],
+                'rest': ['docutils>=0.3', 'pack ==1.1, ==1.3']
+            }
+
+    def test_entry_points(self, tmpdir):
+        fake_env(
+            tmpdir,
+            '[options.entry_points]\n'
+            'group1 = point1 = pack.module:func, '
+                '.point2 = pack.module2:func_rest [rest]\n'
+            'group2 = point3 = pack.module:func2\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            assert dist.entry_points == {
+                'group1': [
+                    'point1 = pack.module:func',
+                    '.point2 = pack.module2:func_rest [rest]',
+                ],
+                'group2': ['point3 = pack.module:func2']
+            }
+
+        expected = (
+            '[blogtool.parsers]\n'
+            '.rst = some.nested.module:SomeClass.some_classmethod[reST]\n'
+        )
+
+        tmpdir.join('entry_points').write(expected)
+
+        # From file.
+        tmpdir.join('setup.cfg').write(
+            '[options]\n'
+            'entry_points = file: entry_points\n'
+        )
+
+        with get_dist(tmpdir) as dist:
+            assert dist.entry_points == expected