Commit 2c897b5b authored by Benoit Pierre's avatar Benoit Pierre

improve encoding handling for `setup.cfg`

Support the same mechanism as for Python sources for declaring
the encoding to be used when reading `setup.cfg` (see PEP 263),
and return the results of reading it as Unicode.

Fix #1062 and #1136.
parent 3686dedb
...@@ -4,9 +4,12 @@ import os ...@@ -4,9 +4,12 @@ import os
import functools import functools
import distutils.core import distutils.core
import distutils.filelist import distutils.filelist
import re
from distutils.errors import DistutilsOptionError
from distutils.util import convert_path from distutils.util import convert_path
from fnmatch import fnmatchcase from fnmatch import fnmatchcase
from setuptools.extern.six import string_types
from setuptools.extern.six.moves import filter, map from setuptools.extern.six.moves import filter, map
import setuptools.version import setuptools.version
...@@ -127,6 +130,37 @@ class Command(_Command): ...@@ -127,6 +130,37 @@ class Command(_Command):
_Command.__init__(self, dist) _Command.__init__(self, dist)
vars(self).update(kw) vars(self).update(kw)
def _ensure_stringlike(self, option, what, default=None):
val = getattr(self, option)
if val is None:
setattr(self, option, default)
return default
elif not isinstance(val, string_types):
raise DistutilsOptionError("'%s' must be a %s (got `%s`)"
% (option, what, val))
return val
def ensure_string_list(self, option):
r"""Ensure that 'option' is a list of strings. If 'option' is
currently a string, we split it either on /,\s*/ or /\s+/, so
"foo bar baz", "foo,bar,baz", and "foo, bar baz" all become
["foo", "bar", "baz"].
"""
val = getattr(self, option)
if val is None:
return
elif isinstance(val, string_types):
setattr(self, option, re.split(r',\s*|\s+', val))
else:
if isinstance(val, list):
ok = all(isinstance(v, string_types) for v in val)
else:
ok = False
if not ok:
raise DistutilsOptionError(
"'%s' must be a list of strings (got %r)"
% (option, val))
def reinitialize_command(self, command, reinit_subcommands=0, **kw): def reinitialize_command(self, command, reinit_subcommands=0, **kw):
cmd = _Command.reinitialize_command(self, command, reinit_subcommands) cmd = _Command.reinitialize_command(self, command, reinit_subcommands)
vars(cmd).update(kw) vars(cmd).update(kw)
......
...@@ -432,7 +432,7 @@ class Distribution(Distribution_parse_config_files, _Distribution): ...@@ -432,7 +432,7 @@ class Distribution(Distribution_parse_config_files, _Distribution):
and loads configuration. and loads configuration.
""" """
_Distribution.parse_config_files(self, filenames=filenames) Distribution_parse_config_files.parse_config_files(self, filenames=filenames)
parse_configuration(self, self.command_options) parse_configuration(self, self.command_options)
self._finalize_requires() self._finalize_requires()
......
import io
import re
import sys import sys
from distutils.errors import DistutilsOptionError from distutils.errors import DistutilsOptionError
from distutils.util import strtobool from distutils.util import strtobool
from distutils.debug import DEBUG from distutils.debug import DEBUG
from setuptools.extern import six
CODING_RE = re.compile(br'^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)')
def detect_encoding(fp):
first_line = fp.readline()
fp.seek(0)
m = CODING_RE.match(first_line)
if m is None:
return None
return m.group(1).decode('ascii')
class Distribution_parse_config_files: class Distribution_parse_config_files:
...@@ -13,10 +27,10 @@ class Distribution_parse_config_files: ...@@ -13,10 +27,10 @@ class Distribution_parse_config_files:
as implemented in distutils. as implemented in distutils.
""" """
def parse_config_files(self, filenames=None): def parse_config_files(self, filenames=None):
from configparser import ConfigParser from setuptools.extern.six.moves.configparser import ConfigParser
# Ignore install directory options if we have a venv # Ignore install directory options if we have a venv
if sys.prefix != sys.base_prefix: if six.PY3 and sys.prefix != sys.base_prefix:
ignore_options = [ ignore_options = [
'install-base', 'install-platbase', 'install-lib', 'install-base', 'install-platbase', 'install-lib',
'install-platlib', 'install-purelib', 'install-headers', 'install-platlib', 'install-purelib', 'install-headers',
...@@ -33,11 +47,16 @@ class Distribution_parse_config_files: ...@@ -33,11 +47,16 @@ class Distribution_parse_config_files:
if DEBUG: if DEBUG:
self.announce("Distribution.parse_config_files():") self.announce("Distribution.parse_config_files():")
parser = ConfigParser(interpolation=None) parser = ConfigParser()
for filename in filenames: for filename in filenames:
if DEBUG: with io.open(filename, 'rb') as fp:
self.announce(" reading %s" % filename) encoding = detect_encoding(fp)
parser.read(filename) if DEBUG:
self.announce(" reading %s [%s]" % (
filename, encoding or 'locale')
)
reader = io.TextIOWrapper(fp, encoding=encoding)
(parser.read_file if six.PY3 else parser.readfp)(reader)
for section in parser.sections(): for section in parser.sections():
options = parser.options(section) options = parser.options(section)
opt_dict = self.get_option_dict(section) opt_dict = self.get_option_dict(section)
...@@ -69,12 +88,6 @@ class Distribution_parse_config_files: ...@@ -69,12 +88,6 @@ class Distribution_parse_config_files:
raise DistutilsOptionError(msg) raise DistutilsOptionError(msg)
if sys.version_info < (3,):
# Python 2 behavior is sufficient
class Distribution_parse_config_files:
pass
if False: if False:
# When updated behavior is available upstream, # When updated behavior is available upstream,
# disable override here. # disable override here.
......
# -*- coding: UTF-8 -*-
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
from setuptools.dist import Distribution from setuptools.dist import Distribution
from setuptools.config import ConfigHandler, read_configuration from setuptools.config import ConfigHandler, read_configuration
from setuptools.extern.six.moves.configparser import InterpolationMissingOptionError from setuptools.extern.six.moves.configparser import InterpolationMissingOptionError
from setuptools.tests import is_ascii
class ErrConfigHandler(ConfigHandler): class ErrConfigHandler(ConfigHandler):
...@@ -17,7 +21,7 @@ def make_package_dir(name, base_dir): ...@@ -17,7 +21,7 @@ def make_package_dir(name, base_dir):
return dir_package, init_file return dir_package, init_file
def fake_env(tmpdir, setup_cfg, setup_py=None): def fake_env(tmpdir, setup_cfg, setup_py=None, encoding='ascii'):
if setup_py is None: if setup_py is None:
setup_py = ( setup_py = (
...@@ -27,7 +31,7 @@ def fake_env(tmpdir, setup_cfg, setup_py=None): ...@@ -27,7 +31,7 @@ def fake_env(tmpdir, setup_cfg, setup_py=None):
tmpdir.join('setup.py').write(setup_py) tmpdir.join('setup.py').write(setup_py)
config = tmpdir.join('setup.cfg') config = tmpdir.join('setup.cfg')
config.write(setup_cfg) config.write(setup_cfg.encode(encoding), mode='wb')
package_dir, init_file = make_package_dir('fake_package', tmpdir) package_dir, init_file = make_package_dir('fake_package', tmpdir)
...@@ -317,6 +321,63 @@ class TestMetadata: ...@@ -317,6 +321,63 @@ class TestMetadata:
with get_dist(tmpdir): with get_dist(tmpdir):
pass pass
skip_if_not_ascii = pytest.mark.skipif(not is_ascii, reason='Test not supported with this locale')
@skip_if_not_ascii
def test_non_ascii_1(self, tmpdir):
fake_env(
tmpdir,
'[metadata]\n'
'description = éàïôñ\n',
encoding='utf-8'
)
with pytest.raises(UnicodeDecodeError):
with get_dist(tmpdir):
pass
def test_non_ascii_2(self, tmpdir):
fake_env(
tmpdir,
'# -*- coding: invalid\n'
)
with pytest.raises(LookupError):
with get_dist(tmpdir):
pass
def test_non_ascii_3(self, tmpdir):
fake_env(
tmpdir,
'\n'
'# -*- coding: invalid\n'
)
with get_dist(tmpdir):
pass
@skip_if_not_ascii
def test_non_ascii_4(self, tmpdir):
fake_env(
tmpdir,
'# -*- coding: utf-8\n'
'[metadata]\n'
'description = éàïôñ\n',
encoding='utf-8'
)
with get_dist(tmpdir) as dist:
assert dist.metadata.description == 'éàïôñ'
@skip_if_not_ascii
def test_non_ascii_5(self, tmpdir):
fake_env(
tmpdir,
'# vim: set fileencoding=iso-8859-15 :\n'
'[metadata]\n'
'description = éàïôñ\n',
encoding='iso-8859-15'
)
with get_dist(tmpdir) as dist:
assert dist.metadata.description == 'éàïôñ'
class TestOptions: class TestOptions:
def test_basic(self, tmpdir): def test_basic(self, tmpdir):
......
...@@ -497,3 +497,49 @@ class TestEggInfo(object): ...@@ -497,3 +497,49 @@ class TestEggInfo(object):
# expect exactly one result # expect exactly one result
result, = results result, = results
return result return result
EGG_INFO_TESTS = (
# Check for issue #1136: invalid string type when
# reading declarative `setup.cfg` under Python 2.
{
'setup.py': DALS(
"""
from setuptools import setup
setup(
name="foo",
)
"""),
'setup.cfg': DALS(
"""
[options]
package_dir =
= src
"""),
'src': {},
},
# Check Unicode can be used in `setup.py` under Python 2.
{
'setup.py': DALS(
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from setuptools import setup, find_packages
setup(
name="foo",
package_dir={'': 'src'},
)
"""),
'src': {},
}
)
@pytest.mark.parametrize('package_files', EGG_INFO_TESTS)
def test_egg_info(self, tmpdir_cwd, env, package_files):
"""
"""
build_files(package_files)
code, data = environment.run_setup_py(
cmd=['egg_info'],
data_stream=1,
)
assert not code, data
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