Repository: pypa/setuptools

Issue:
TypeError: 'output_dir' must be a string or None since setuptools 40.7.0
We have a [tricky regression](https://bugs.gentoo.org/676608) in Gentoo with setuptools release 40.7.0. This affects many (all?) packages that involve `build_ext`. These errors aren't present in 40.6.3. I think I managed to have reproducible steps:

```
git clone https://github.com/python-pillow/Pillow.git
cd Pillow
virtualenv -p python2 env
. env/bin/activate
pip install -U setuptools
echo -e "[build]\nbuild-base = /tmp/foo" >> setup.cfg
mkdir /tmp/foo
python setup.py build
[...]
Traceback (most recent call last):
  File "setup.py", line 793, in <module>
    zip_safe=not (debug_build() or PLATFORM_MINGW), )
  File "/home/vdupras/src/Pillow/env/lib/python2.7/site-packages/setuptools/__init__.py", line 145, in setup
    return distutils.core.setup(**attrs)
  File "/usr/lib64/python2.7/distutils/core.py", line 151, in setup
    dist.run_commands()
  File "/usr/lib64/python2.7/distutils/dist.py", line 953, in run_commands
    self.run_command(cmd)
  File "/usr/lib64/python2.7/distutils/dist.py", line 972, in run_command
    cmd_obj.run()
  File "/usr/lib64/python2.7/distutils/command/build.py", line 127, in run
    self.run_command(cmd_name)
  File "/usr/lib64/python2.7/distutils/cmd.py", line 326, in run_command
    self.distribution.run_command(command)
  File "/usr/lib64/python2.7/distutils/dist.py", line 972, in run_command
    cmd_obj.run()
  File "/usr/lib64/python2.7/distutils/command/build_ext.py", line 340, in run
    self.build_extensions()
  File "setup.py", line 695, in build_extensions
    build_ext.build_extensions(self)
  File "/usr/lib64/python2.7/distutils/command/build_ext.py", line 449, in build_extensions
    self.build_extension(ext)
  File "/usr/lib64/python2.7/distutils/command/build_ext.py", line 499, in build_extension
    depends=ext.depends)
  File "/home/vdupras/src/Pillow/mp_compile.py", line 40, in _mp_compile
    output_dir, macros, include_dirs, sources, depends, extra_postargs)
  File "/usr/lib64/python2.7/distutils/ccompiler.py", line 329, in _setup_compile
    raise TypeError, "'output_dir' must be a string or None"
TypeError: 'output_dir' must be a string or None
```

Can you reproduce on your side?

It the fix isn't obvious, I can try myself on a PR soon.

(by the way, this is due to distutils's `isinstance()` call being made on `str` rather than `basestring` and only happens under py2. `output_dir` is unicode when it should be `str`)
TypeError: 'output_dir' must be a string or None since setuptools 40.7.0
We have a [tricky regression](https://bugs.gentoo.org/676608) in Gentoo with setuptools release 40.7.0. This affects many (all?) packages that involve `build_ext`. These errors aren't present in 40.6.3. I think I managed to have reproducible steps:

```
git clone https://github.com/python-pillow/Pillow.git
cd Pillow
virtualenv -p python2 env
. env/bin/activate
pip install -U setuptools
echo -e "[build]\nbuild-base = /tmp/foo" >> setup.cfg
mkdir /tmp/foo
python setup.py build
[...]
Traceback (most recent call last):
  File "setup.py", line 793, in <module>
    zip_safe=not (debug_build() or PLATFORM_MINGW), )
  File "/home/vdupras/src/Pillow/env/lib/python2.7/site-packages/setuptools/__init__.py", line 145, in setup
    return distutils.core.setup(**attrs)
  File "/usr/lib64/python2.7/distutils/core.py", line 151, in setup
    dist.run_commands()
  File "/usr/lib64/python2.7/distutils/dist.py", line 953, in run_commands
    self.run_command(cmd)
  File "/usr/lib64/python2.7/distutils/dist.py", line 972, in run_command
    cmd_obj.run()
  File "/usr/lib64/python2.7/distutils/command/build.py", line 127, in run
    self.run_command(cmd_name)
  File "/usr/lib64/python2.7/distutils/cmd.py", line 326, in run_command
    self.distribution.run_command(command)
  File "/usr/lib64/python2.7/distutils/dist.py", line 972, in run_command
    cmd_obj.run()
  File "/usr/lib64/python2.7/distutils/command/build_ext.py", line 340, in run
    self.build_extensions()
  File "setup.py", line 695, in build_extensions
    build_ext.build_extensions(self)
  File "/usr/lib64/python2.7/distutils/command/build_ext.py", line 449, in build_extensions
    self.build_extension(ext)
  File "/usr/lib64/python2.7/distutils/command/build_ext.py", line 499, in build_extension
    depends=ext.depends)
  File "/home/vdupras/src/Pillow/mp_compile.py", line 40, in _mp_compile
    output_dir, macros, include_dirs, sources, depends, extra_postargs)
  File "/usr/lib64/python2.7/distutils/ccompiler.py", line 329, in _setup_compile
    raise TypeError, "'output_dir' must be a string or None"
TypeError: 'output_dir' must be a string or None
```

Can you reproduce on your side?

It the fix isn't obvious, I can try myself on a PR soon.

(by the way, this is due to distutils's `isinstance()` call being made on `str` rather than `basestring` and only happens under py2. `output_dir` is unicode when it should be `str`)


Discussion:
This is probably due to #1180.
I think something similar is the cause of a zc.buildout test error. It only fails on 2.7 (pypy and python 3 are file) and it happens when we test a package with an extension.

It has to do with `include_dirs` instead of `output_dir`, but it sounds similar enough that the traceback (from travis-ci) could help:

    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 2316, in main
	**kw
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/__init__.py", line 145, in setup
	return distutils.core.setup(**attrs)
      File ".../python2.7/distutils/core.py", line 151, in setup
	dist.run_commands()
      File ".../python2.7/distutils/dist.py", line 953, in run_commands
	self.run_command(cmd)
      File ".../python2.7/distutils/dist.py", line 972, in run_command
	cmd_obj.run()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 418, in run
	self.easy_install(spec, not self.no_deps)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 660, in easy_install
	return self.install_item(None, spec, tmpdir, deps, True)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 705, in install_item
	dists = self.install_eggs(spec, download, tmpdir)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 890, in install_eggs
	return self.build_and_install(setup_script, setup_base)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 1158, in build_and_install
	self.run_setup(setup_script, setup_base, args)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 1144, in run_setup
	run_setup(setup_script, args)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 253, in run_setup
	raise
      File ".../python2.7/contextlib.py", line 35, in __exit__
	self.gen.throw(type, value, traceback)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 195, in setup_context
	yield
      File ".../python2.7/contextlib.py", line 35, in __exit__
	self.gen.throw(type, value, traceback)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 166, in save_modules
	saved_exc.resume()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 141, in resume
	six.reraise(type, exc, self._tb)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 154, in save_modules
	yield saved
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 195, in setup_context
	yield
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 250, in run_setup
	_execfile(setup_script, ns)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 45, in _execfile
	exec(code, globals, locals)
      File "/tmp/tmpc2rzJKbuild/extdemo-1.5/setup.py", line 10, in <module>
	ext_modules = [Extension('extdemo', ['extdemo.c'])],
      File ".../python2.7/distutils/core.py", line 151, in setup
	dist.run_commands()
      File ".../python2.7/distutils/dist.py", line 953, in run_commands
	self.run_command(cmd)
      File ".../python2.7/distutils/dist.py", line 972, in run_command
	cmd_obj.run()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/bdist_egg.py", line 163, in run
	self.run_command("egg_info")
      File ".../python2.7/distutils/cmd.py", line 326, in run_command
	self.distribution.run_command(command)
      File ".../python2.7/distutils/dist.py", line 972, in run_command
	cmd_obj.run()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/egg_info.py", line 296, in run
	self.find_sources()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/egg_info.py", line 303, in find_sources
	mm.run()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/egg_info.py", line 534, in run
	self.add_defaults()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/egg_info.py", line 570, in add_defaults
	sdist.add_defaults(self)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/py36compat.py", line 36, in add_defaults
	self._add_defaults_ext()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/py36compat.py", line 119, in _add_defaults_ext
	build_ext = self.get_finalized_command('build_ext')
      File ".../python2.7/distutils/cmd.py", line 312, in get_finalized_command
	cmd_obj.ensure_finalized()
      File ".../python2.7/distutils/cmd.py", line 109, in ensure_finalized
	self.finalize_options()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/build_ext.py", line 133, in finalize_options
	_build_ext.finalize_options(self)
      File ".../python2.7/distutils/command/build_ext.py", line 159, in finalize_options
	self.include_dirs.append(py_include)
    AttributeError: 'unicode' object has no attribute 'append'

`self.include_dirs` apparently is a unicode object instead of a list.

I haven't myself contributed to that part of the buildout tests, but from looking at it, we're passing the result of an `os.path.join()` as `include_dirs` in a dict to `setuptools.command.setopt.edit_config()`. (I don't know what happens in there, though).
I've looked at my local 2.7 distutils, and in `distutils/command/build_ext.py` around line 150 I see something like this:

        if self.include_dirs is None:
            self.include_dirs = self.distribution.include_dirs or []
        if isinstance(self.include_dirs, str):
            self.include_dirs = self.include_dirs.split(os.pathsep)

That `self.distribution.include_dirs` is probably the `os.path.join()` output buildout puts into `include_dirs`.
The `isinstance` normally detected this as `str` and called "split", turning it into the (expected) list.

Now the value is a unicode instance, so this is another case where `string_types` should be used.

I did a bit of grepping, and there are some 25 cases where I see `isinstance(something, str)`. #1180 didn't get all of them. Note that it will be a pain to fix them all with monkey patching...

Brainstorming: would it be an idea to stick with old-style strings on 2.7? I don't know exactly what #1180 fixes, but perhaps encoding it as byte strings again (only on 2.7) could be OK?
Or revert the change, postpone it for v41 and make that release py3-only? *isittimeyet?*
I'm seeing this for pypy as well, installing packages such as grpcio
I don't yet understand which part of the change in #1180 led to these failures. Reading through the code, it seems to me the only place that unicode would appear where previously str would be present is in the .cfg file parsing, and I don't see those config values set in setup.cfg... unless maybe the build tools are setting those values. Yes, that must be it...

Confirmed. As can be seen from the trace, the following is being set in setup.cfg:

```
[build]
build-base=/tmp/foo
```

And tracing the distutils code, I can see where output_dir is derived from build_temp and build_temp is derived from build_base.
This is probably due to #1180.
I think something similar is the cause of a zc.buildout test error. It only fails on 2.7 (pypy and python 3 are file) and it happens when we test a package with an extension.

It has to do with `include_dirs` instead of `output_dir`, but it sounds similar enough that the traceback (from travis-ci) could help:

    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 2316, in main
	**kw
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/__init__.py", line 145, in setup
	return distutils.core.setup(**attrs)
      File ".../python2.7/distutils/core.py", line 151, in setup
	dist.run_commands()
      File ".../python2.7/distutils/dist.py", line 953, in run_commands
	self.run_command(cmd)
      File ".../python2.7/distutils/dist.py", line 972, in run_command
	cmd_obj.run()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 418, in run
	self.easy_install(spec, not self.no_deps)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 660, in easy_install
	return self.install_item(None, spec, tmpdir, deps, True)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 705, in install_item
	dists = self.install_eggs(spec, download, tmpdir)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 890, in install_eggs
	return self.build_and_install(setup_script, setup_base)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 1158, in build_and_install
	self.run_setup(setup_script, setup_base, args)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/easy_install.py", line 1144, in run_setup
	run_setup(setup_script, args)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 253, in run_setup
	raise
      File ".../python2.7/contextlib.py", line 35, in __exit__
	self.gen.throw(type, value, traceback)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 195, in setup_context
	yield
      File ".../python2.7/contextlib.py", line 35, in __exit__
	self.gen.throw(type, value, traceback)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 166, in save_modules
	saved_exc.resume()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 141, in resume
	six.reraise(type, exc, self._tb)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 154, in save_modules
	yield saved
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 195, in setup_context
	yield
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 250, in run_setup
	_execfile(setup_script, ns)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/sandbox.py", line 45, in _execfile
	exec(code, globals, locals)
      File "/tmp/tmpc2rzJKbuild/extdemo-1.5/setup.py", line 10, in <module>
	ext_modules = [Extension('extdemo', ['extdemo.c'])],
      File ".../python2.7/distutils/core.py", line 151, in setup
	dist.run_commands()
      File ".../python2.7/distutils/dist.py", line 953, in run_commands
	self.run_command(cmd)
      File ".../python2.7/distutils/dist.py", line 972, in run_command
	cmd_obj.run()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/bdist_egg.py", line 163, in run
	self.run_command("egg_info")
      File ".../python2.7/distutils/cmd.py", line 326, in run_command
	self.distribution.run_command(command)
      File ".../python2.7/distutils/dist.py", line 972, in run_command
	cmd_obj.run()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/egg_info.py", line 296, in run
	self.find_sources()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/egg_info.py", line 303, in find_sources
	mm.run()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/egg_info.py", line 534, in run
	self.add_defaults()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/egg_info.py", line 570, in add_defaults
	sdist.add_defaults(self)
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/py36compat.py", line 36, in add_defaults
	self._add_defaults_ext()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/py36compat.py", line 119, in _add_defaults_ext
	build_ext = self.get_finalized_command('build_ext')
      File ".../python2.7/distutils/cmd.py", line 312, in get_finalized_command
	cmd_obj.ensure_finalized()
      File ".../python2.7/distutils/cmd.py", line 109, in ensure_finalized
	self.finalize_options()
      File ".../setuptools-40.7.0-py2.7.egg/setuptools/command/build_ext.py", line 133, in finalize_options
	_build_ext.finalize_options(self)
      File ".../python2.7/distutils/command/build_ext.py", line 159, in finalize_options
	self.include_dirs.append(py_include)
    AttributeError: 'unicode' object has no attribute 'append'

`self.include_dirs` apparently is a unicode object instead of a list.

I haven't myself contributed to that part of the buildout tests, but from looking at it, we're passing the result of an `os.path.join()` as `include_dirs` in a dict to `setuptools.command.setopt.edit_config()`. (I don't know what happens in there, though).
I've looked at my local 2.7 distutils, and in `distutils/command/build_ext.py` around line 150 I see something like this:

        if self.include_dirs is None:
            self.include_dirs = self.distribution.include_dirs or []
        if isinstance(self.include_dirs, str):
            self.include_dirs = self.include_dirs.split(os.pathsep)

That `self.distribution.include_dirs` is probably the `os.path.join()` output buildout puts into `include_dirs`.
The `isinstance` normally detected this as `str` and called "split", turning it into the (expected) list.

Now the value is a unicode instance, so this is another case where `string_types` should be used.

I did a bit of grepping, and there are some 25 cases where I see `isinstance(something, str)`. #1180 didn't get all of them. Note that it will be a pain to fix them all with monkey patching...

Brainstorming: would it be an idea to stick with old-style strings on 2.7? I don't know exactly what #1180 fixes, but perhaps encoding it as byte strings again (only on 2.7) could be OK?
Or revert the change, postpone it for v41 and make that release py3-only? *isittimeyet?*
I'm seeing this for pypy as well, installing packages such as grpcio
I don't yet understand which part of the change in #1180 led to these failures. Reading through the code, it seems to me the only place that unicode would appear where previously str would be present is in the .cfg file parsing, and I don't see those config values set in setup.cfg... unless maybe the build tools are setting those values. Yes, that must be it...

Confirmed. As can be seen from the trace, the following is being set in setup.cfg:

```
[build]
build-base=/tmp/foo
```

And tracing the distutils code, I can see where output_dir is derived from build_temp and build_temp is derived from build_base.

Resolving patch:
diff --git a/setuptools/dist.py b/setuptools/dist.py
--- a/setuptools/dist.py
+++ b/setuptools/dist.py
@@ -603,7 +603,7 @@ def _parse_config_files(self, filenames=None):
 
                 for opt in options:
                     if opt != '__name__' and opt not in ignore_options:
-                        val = parser.get(section, opt)
+                        val = self._try_str(parser.get(section, opt))
                         opt = opt.replace('-', '_')
                         opt_dict[opt] = (filename, val)
 
@@ -627,6 +627,26 @@ def _parse_config_files(self, filenames=None):
                 except ValueError as msg:
                     raise DistutilsOptionError(msg)
 
+    @staticmethod
+    def _try_str(val):
+        """
+        On Python 2, much of distutils relies on string values being of
+        type 'str' (bytes) and not unicode text. If the value can be safely
+        encoded to bytes using the default encoding, prefer that.
+
+        Why the default encoding? Because that value can be implicitly
+        decoded back to text if needed.
+
+        Ref #1653
+        """
+        if six.PY3:
+            return val
+        try:
+            return val.encode()
+        except UnicodeEncodeError:
+            pass
+        return val
+
     def _set_command_options(self, command_obj, option_dict=None):
         """
         Set the options for 'command_obj' from 'option_dict'.  Basically


Test patch:
diff --git a/setuptools/tests/environment.py b/setuptools/tests/environment.py
--- a/setuptools/tests/environment.py
+++ b/setuptools/tests/environment.py
@@ -46,6 +46,8 @@ def run_setup_py(cmd, pypath=None, path=None,
             cmd, stdout=_PIPE, stderr=_PIPE, shell=shell, env=env,
         )
 
+        if isinstance(data_stream, tuple):
+            data_stream = slice(*data_stream)
         data = proc.communicate()[data_stream]
     except OSError:
         return 1, ''
diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py
--- a/setuptools/tests/test_build_ext.py
+++ b/setuptools/tests/test_build_ext.py
@@ -8,6 +8,10 @@
 from setuptools.dist import Distribution
 from setuptools.extension import Extension
 
+from . import environment
+from .files import build_files
+from .textwrap import DALS
+
 
 class TestBuildExt:
     def test_get_ext_filename(self):
@@ -43,3 +47,69 @@ def test_abi3_filename(self):
             assert res.endswith('eggs.pyd')
         else:
             assert 'abi3' in res
+
+
+def test_build_ext_config_handling(tmpdir_cwd):
+    files = {
+        'setup.py': DALS(
+            """
+            from setuptools import Extension, setup
+            setup(
+                name='foo',
+                version='0.0.0',
+                ext_modules=[Extension('foo', ['foo.c'])],
+            )
+            """),
+        'foo.c': DALS(
+            """
+            #include "Python.h"
+
+            #if PY_MAJOR_VERSION >= 3
+
+            static struct PyModuleDef moduledef = {
+                    PyModuleDef_HEAD_INIT,
+                    "foo",
+                    NULL,
+                    0,
+                    NULL,
+                    NULL,
+                    NULL,
+                    NULL,
+                    NULL
+            };
+
+            #define INITERROR return NULL
+
+            PyMODINIT_FUNC PyInit_foo(void)
+
+            #else
+
+            #define INITERROR return
+
+            void initfoo(void)
+
+            #endif
+            {
+            #if PY_MAJOR_VERSION >= 3
+                PyObject *module = PyModule_Create(&moduledef);
+            #else
+                PyObject *module = Py_InitModule("extension", NULL);
+            #endif
+                if (module == NULL)
+                    INITERROR;
+            #if PY_MAJOR_VERSION >= 3
+                return module;
+            #endif
+            }
+            """),
+        'setup.cfg': DALS(
+            """
+            [build]
+            build-base = foo_build
+            """),
+    }
+    build_files(files)
+    code, output = environment.run_setup_py(
+        cmd=['build'], data_stream=(0, 2),
+    )
+    assert code == 0, '\nSTDOUT:\n%s\nSTDERR:\n%s' % output