Skip to content

Commit e110db5

Browse files
committed
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.
1 parent 3686ded commit e110db5

File tree

5 files changed

+144
-15
lines changed

5 files changed

+144
-15
lines changed

setuptools/__init__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
import functools
55
import distutils.core
66
import distutils.filelist
7+
import re
8+
from distutils.errors import DistutilsOptionError
79
from distutils.util import convert_path
810
from fnmatch import fnmatchcase
911

12+
from setuptools.extern.six import string_types
1013
from setuptools.extern.six.moves import filter, map
1114

1215
import setuptools.version
@@ -127,6 +130,37 @@ def __init__(self, dist, **kw):
127130
_Command.__init__(self, dist)
128131
vars(self).update(kw)
129132

133+
def _ensure_stringlike(self, option, what, default=None):
134+
val = getattr(self, option)
135+
if val is None:
136+
setattr(self, option, default)
137+
return default
138+
elif not isinstance(val, string_types):
139+
raise DistutilsOptionError("'%s' must be a %s (got `%s`)"
140+
% (option, what, val))
141+
return val
142+
143+
def ensure_string_list(self, option):
144+
r"""Ensure that 'option' is a list of strings. If 'option' is
145+
currently a string, we split it either on /,\s*/ or /\s+/, so
146+
"foo bar baz", "foo,bar,baz", and "foo, bar baz" all become
147+
["foo", "bar", "baz"].
148+
"""
149+
val = getattr(self, option)
150+
if val is None:
151+
return
152+
elif isinstance(val, string_types):
153+
setattr(self, option, re.split(r',\s*|\s+', val))
154+
else:
155+
if isinstance(val, list):
156+
ok = all(isinstance(v, string_types) for v in val)
157+
else:
158+
ok = False
159+
if not ok:
160+
raise DistutilsOptionError(
161+
"'%s' must be a list of strings (got %r)"
162+
% (option, val))
163+
130164
def reinitialize_command(self, command, reinit_subcommands=0, **kw):
131165
cmd = _Command.reinitialize_command(self, command, reinit_subcommands)
132166
vars(cmd).update(kw)

setuptools/dist.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ def parse_config_files(self, filenames=None):
432432
and loads configuration.
433433
434434
"""
435-
_Distribution.parse_config_files(self, filenames=filenames)
435+
Distribution_parse_config_files.parse_config_files(self, filenames=filenames)
436436

437437
parse_configuration(self, self.command_options)
438438
self._finalize_requires()

setuptools/py36compat.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
1+
import io
2+
import re
13
import sys
24
from distutils.errors import DistutilsOptionError
35
from distutils.util import strtobool
46
from distutils.debug import DEBUG
7+
from setuptools.extern import six
8+
9+
10+
CODING_RE = re.compile(br'^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)')
11+
12+
def detect_encoding(fp):
13+
first_line = fp.readline()
14+
fp.seek(0)
15+
m = CODING_RE.match(first_line)
16+
if m is None:
17+
return None
18+
return m.group(1).decode('ascii')
519

620

721
class Distribution_parse_config_files:
@@ -13,10 +27,10 @@ class Distribution_parse_config_files:
1327
as implemented in distutils.
1428
"""
1529
def parse_config_files(self, filenames=None):
16-
from configparser import ConfigParser
30+
from setuptools.extern.six.moves.configparser import ConfigParser
1731

1832
# Ignore install directory options if we have a venv
19-
if sys.prefix != sys.base_prefix:
33+
if six.PY3 and sys.prefix != sys.base_prefix:
2034
ignore_options = [
2135
'install-base', 'install-platbase', 'install-lib',
2236
'install-platlib', 'install-purelib', 'install-headers',
@@ -33,11 +47,16 @@ def parse_config_files(self, filenames=None):
3347
if DEBUG:
3448
self.announce("Distribution.parse_config_files():")
3549

36-
parser = ConfigParser(interpolation=None)
50+
parser = ConfigParser()
3751
for filename in filenames:
38-
if DEBUG:
39-
self.announce(" reading %s" % filename)
40-
parser.read(filename)
52+
with io.open(filename, 'rb') as fp:
53+
encoding = detect_encoding(fp)
54+
if DEBUG:
55+
self.announce(" reading %s [%s]" % (
56+
filename, encoding or 'locale')
57+
)
58+
reader = io.TextIOWrapper(fp, encoding=encoding)
59+
(parser.read_file if six.PY3 else parser.readfp)(reader)
4160
for section in parser.sections():
4261
options = parser.options(section)
4362
opt_dict = self.get_option_dict(section)
@@ -69,12 +88,6 @@ def parse_config_files(self, filenames=None):
6988
raise DistutilsOptionError(msg)
7089

7190

72-
if sys.version_info < (3,):
73-
# Python 2 behavior is sufficient
74-
class Distribution_parse_config_files:
75-
pass
76-
77-
7891
if False:
7992
# When updated behavior is available upstream,
8093
# disable override here.

setuptools/tests/test_config.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
# -*- coding: UTF-8 -*-
2+
from __future__ import unicode_literals
3+
14
import contextlib
25
import pytest
36
from distutils.errors import DistutilsOptionError, DistutilsFileError
47
from setuptools.dist import Distribution
58
from setuptools.config import ConfigHandler, read_configuration
69
from setuptools.extern.six.moves.configparser import InterpolationMissingOptionError
10+
from setuptools.tests import is_ascii
711

812

913
class ErrConfigHandler(ConfigHandler):
@@ -17,7 +21,7 @@ def make_package_dir(name, base_dir):
1721
return dir_package, init_file
1822

1923

20-
def fake_env(tmpdir, setup_cfg, setup_py=None):
24+
def fake_env(tmpdir, setup_cfg, setup_py=None, encoding='ascii'):
2125

2226
if setup_py is None:
2327
setup_py = (
@@ -27,7 +31,7 @@ def fake_env(tmpdir, setup_cfg, setup_py=None):
2731

2832
tmpdir.join('setup.py').write(setup_py)
2933
config = tmpdir.join('setup.cfg')
30-
config.write(setup_cfg)
34+
config.write(setup_cfg.encode(encoding), mode='wb')
3135

3236
package_dir, init_file = make_package_dir('fake_package', tmpdir)
3337

@@ -317,6 +321,63 @@ def test_interpolation(self, tmpdir):
317321
with get_dist(tmpdir):
318322
pass
319323

324+
skip_if_not_ascii = pytest.mark.skipif(not is_ascii, reason='Test not supported with this locale')
325+
326+
@skip_if_not_ascii
327+
def test_non_ascii_1(self, tmpdir):
328+
fake_env(
329+
tmpdir,
330+
'[metadata]\n'
331+
'description = éàïôñ\n',
332+
encoding='utf-8'
333+
)
334+
with pytest.raises(UnicodeDecodeError):
335+
with get_dist(tmpdir):
336+
pass
337+
338+
def test_non_ascii_2(self, tmpdir):
339+
fake_env(
340+
tmpdir,
341+
'# -*- coding: invalid\n'
342+
)
343+
with pytest.raises(LookupError):
344+
with get_dist(tmpdir):
345+
pass
346+
347+
def test_non_ascii_3(self, tmpdir):
348+
fake_env(
349+
tmpdir,
350+
'\n'
351+
'# -*- coding: invalid\n'
352+
)
353+
with get_dist(tmpdir):
354+
pass
355+
356+
@skip_if_not_ascii
357+
def test_non_ascii_4(self, tmpdir):
358+
fake_env(
359+
tmpdir,
360+
'# -*- coding: utf-8\n'
361+
'[metadata]\n'
362+
'description = éàïôñ\n',
363+
encoding='utf-8'
364+
)
365+
with get_dist(tmpdir) as dist:
366+
assert dist.metadata.description == 'éàïôñ'
367+
368+
@skip_if_not_ascii
369+
def test_non_ascii_5(self, tmpdir):
370+
fake_env(
371+
tmpdir,
372+
'# vim: set fileencoding=iso-8859-15 :\n'
373+
'[metadata]\n'
374+
'description = éàïôñ\n',
375+
encoding='iso-8859-15'
376+
)
377+
with get_dist(tmpdir) as dist:
378+
assert dist.metadata.description == 'éàïôñ'
379+
380+
320381
class TestOptions:
321382

322383
def test_basic(self, tmpdir):

setuptools/tests/test_egg_info.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,3 +497,24 @@ def __init__(self, files, base):
497497
# expect exactly one result
498498
result, = results
499499
return result
500+
501+
def test_egg_info_with_src_in_setup_cfg(self, tmpdir_cwd, env):
502+
"""
503+
Check for issue #1136: invalid string type when
504+
reading declarative `setup.cfg` under Python 2.
505+
"""
506+
build_files({
507+
'setup.py': DALS(
508+
"""
509+
from setuptools import setup
510+
setup(name="barbazquux", version="4.2")
511+
"""),
512+
'setup.cfg': DALS(
513+
"""
514+
[options]
515+
package_dir =
516+
= src
517+
"""),
518+
'src': { 'barbazquux.py': "" },
519+
})
520+
self._run_install_command(tmpdir_cwd, env)

0 commit comments

Comments
 (0)