Repository: pypa/virtualenv

Issue:
AttributeError: 'str' object has no attribute 'exists'
**Issue**

Describe what's the expected behaviour and what you're observing.
virtualenv throws an attributeerror when 
```python -m virtualenv venv -ppython3 --with-traceback``` is run

**Environment**

Provide at least:
- OS: Ubuntu bionic
- ``pip list`` of the host python where ``virtualenv`` is installed:

```console
apqdirs==1.4.3
aspy.yaml==1.3.0
cached-property==1.5.1
cfgv==3.1.0
distlib==0.3.0
filelock==3.0.12
identify==1.4.11
importlib-metadata==1.5.0
importlib-resources==1.0.2
nodeenv==1.3.5
pre-commit==1.11.1
PyYAML==5.3
six==1.14.0
toml==0.10.0
virtualenv==20.0.7
zipp==3.0.0
  ```

**Output of the virtual environment creation**

Make sure to run the creation with `-vvv --with-traceback`:
Python: 3.6.8
virtualenv: 20.0.7

```console
python -m virtualenv venv -ppython3 --with-traceback
[2020-02-26T19:04:25.421Z] Traceback (most recent call last):
[2020-02-26T19:04:25.421Z]   File "/opt/Python-3.6.8/lib/python3.6/runpy.py", line 193, in _run_module_as_main
[2020-02-26T19:04:25.421Z]     "_main_", mod_spec)
[2020-02-26T19:04:25.421Z]   File "/opt/Python-3.6.8/lib/python3.6/runpy.py", line 85, in _run_code
[2020-02-26T19:04:25.421Z]     exec(code, run_globals)
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/_main_.py", line 61, i
n <module>
[2020-02-26T19:04:25.421Z]     run_with_catch()
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/_main_.py", line 50, i
n run_with_catch
[2020-02-26T19:04:25.421Z]     run(args, options)
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/_main_.py", line 20, i
n run
[2020-02-26T19:04:25.421Z]     session = cli_run(args, options)
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/run/_init_.py", line 2
7, in cli_run
[2020-02-26T19:04:25.421Z]     session = session_via_cli(args, options)
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/run/_init_.py", line 3
7, in session_via_cli
[2020-02-26T19:04:25.421Z]     creator, seeder, activators = tuple(e.create(parser._options) for e in parser._elements)  # create type
s
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/run/_init_.py", line 3
7, in <genexpr>
[2020-02-26T19:04:25.421Z]     creator, seeder, activators = tuple(e.create(parser._options) for e in parser._elements)  # create type
s
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/run/plugin/seeders.py",
line 31, in create
[2020-02-26T19:04:25.421Z]     return self._impl_class(options)
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/seed/via_app_data/via_ap
p_data.py", line 19, in _init_
[2020-02-26T19:04:25.421Z]     super(FromAppData, self)._init_(options)
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/seed/embed/base_embed.py
", line 21, in _init_
[2020-02-26T19:04:25.421Z]     self.extra_search_dir = [i.resolve() for i in options.extra_search_dir if i.exists()]
[2020-02-26T19:04:25.421Z]   File "/tmp/jenkins---PR-4836-12/precommit/lib/python3.6/site-packages/virtualenv/seed/embed/base_embed.py
", line 21, in <listcomp>
[2020-02-26T19:04:25.421Z]     self.extra_search_dir = [i.resolve() for i in options.extra_search_dir if i.exists()]
[2020-02-26T19:04:25.421Z] AttributeError: 'str' object has no attribute 'exists'
[2020-02-26T19:04:25.421Z] ERROR: InvocationError for command /tmp/jenkins---PR-4836-12/precommit/bin/python -m virtualenv venv -ppyth
on3 --with-traceback (exited with code 1)
[2020-02-26T19:04:25.421Z] ___________________________________ summary ____________________________________
[2020-02-26T19:04:25.421Z] ERROR:   precommit: commands failed
script returned exit code 1
```


Discussion:

Patch:
diff --git a/docs/changelog/1674.bugfix.rst b/docs/changelog/1674.bugfix.rst
new file mode 100644
index 00000000..c71eeca7
--- /dev/null
+++ b/docs/changelog/1674.bugfix.rst
@@ -0,0 +1,1 @@
+Fix list configuration value parsing from config file or environment variable - by :user:`gaborbernat`.
diff --git a/src/virtualenv/config/cli/parser.py b/src/virtualenv/config/cli/parser.py
index 9cfa63fe..47bedaec 100644
--- a/src/virtualenv/config/cli/parser.py
+++ b/src/virtualenv/config/cli/parser.py
@@ -2,6 +2,8 @@ from __future__ import absolute_import, unicode_literals
 
 from argparse import SUPPRESS, ArgumentDefaultsHelpFormatter, ArgumentParser
 
+from virtualenv.config.convert import get_type
+
 from ..env_var import get_env_var
 from ..ini import IniConfig
 
@@ -35,7 +37,7 @@ class VirtualEnvConfigParser(ArgumentParser):
 
     def _fix_default(self, action):
         if hasattr(action, "default") and hasattr(action, "dest") and action.default != SUPPRESS:
-            as_type = type(action.default)
+            as_type = get_type(action)
             outcome = get_env_var(action.dest, as_type)
             if outcome is None and self.file_config:
                 outcome = self.file_config.get(action.dest, as_type)
diff --git a/src/virtualenv/config/convert.py b/src/virtualenv/config/convert.py
index c3852cd0..27821fc0 100644
--- a/src/virtualenv/config/convert.py
+++ b/src/virtualenv/config/convert.py
@@ -1,69 +1,81 @@
 from __future__ import absolute_import, unicode_literals
 
 import logging
+import os
 
-BOOLEAN_STATES = {
-    "1": True,
-    "yes": True,
-    "true": True,
-    "on": True,
-    "0": False,
-    "no": False,
-    "false": False,
-    "off": False,
-}
 
+class TypeData(object):
+    def __init__(self, default_type, as_type):
+        self.default_type = default_type
+        self.as_type = as_type
 
-def _convert_to_boolean(value):
-    if value.lower() not in BOOLEAN_STATES:
-        raise ValueError("Not a boolean: %s" % value)
-    return BOOLEAN_STATES[value.lower()]
+    def __repr__(self):
+        return "{}(base={}, as={})".format(self.__class__.__name__, self.default_type, self.as_type)
 
+    def convert(self, value):
+        return self.default_type(value)
 
-def _expand_to_list(value):
-    if isinstance(value, (str, bytes)):
-        value = filter(None, [x.strip() for x in value.splitlines()])
-    return list(value)
 
+class BoolType(TypeData):
+    BOOLEAN_STATES = {
+        "1": True,
+        "yes": True,
+        "true": True,
+        "on": True,
+        "0": False,
+        "no": False,
+        "false": False,
+        "off": False,
+    }
 
-def _as_list(value, flatten=True):
-    values = _expand_to_list(value)
-    if not flatten:
-        return values  # pragma: no cover
-    result = []
-    for value in values:
-        sub_values = value.split()
-        result.extend(sub_values)
-    return result
+    def convert(self, value):
+        if value.lower() not in self.BOOLEAN_STATES:
+            raise ValueError("Not a boolean: %s" % value)
+        return self.BOOLEAN_STATES[value.lower()]
 
 
-def _as_none(value):
-    if not value:
-        return None
-    return str(value)
+class NoneType(TypeData):
+    def convert(self, value):
+        if not value:
+            return None
+        return str(value)
 
 
-CONVERT = {bool: _convert_to_boolean, list: _as_list, type(None): _as_none}
+class ListType(TypeData):
+    def _validate(self):
+        """"""
 
-
-def _get_converter(as_type):
-    for of_type, func in CONVERT.items():
-        if issubclass(as_type, of_type):
-            getter = func
-            break
-    else:
-        getter = as_type
-    return getter
+    def convert(self, value, flatten=True):
+        if isinstance(value, (str, bytes)):
+            value = filter(None, [x.strip() for x in value.splitlines()])
+        values = list(value)
+        result = []
+        for value in values:
+            sub_values = value.split(os.pathsep)
+            result.extend(sub_values)
+        converted = [self.as_type(i) for i in result]
+        return converted
 
 
 def convert(value, as_type, source):
     """Convert the value as a given type where the value comes from the given source"""
-    getter = _get_converter(as_type)
     try:
-        return getter(value)
+        return as_type.convert(value)
     except Exception as exception:
-        logging.warning("%s failed to convert %r as %r because %r", source, value, getter, exception)
+        logging.warning("%s failed to convert %r as %r because %r", source, value, as_type, exception)
         raise
 
 
-__all__ = ("convert",)
+_CONVERT = {bool: BoolType, type(None): NoneType, list: ListType}
+
+
+def get_type(action):
+    default_type = type(action.default)
+    as_type = default_type if action.type is None else action.type
+    return _CONVERT.get(default_type, TypeData)(default_type, as_type)
+
+
+__all__ = (
+    "convert",
+    "get_type",
+)


Test patch:
diff --git a/tests/unit/config/test_env_var.py b/tests/unit/config/test_env_var.py
index a439b6d3..14463f08 100644
--- a/tests/unit/config/test_env_var.py
+++ b/tests/unit/config/test_env_var.py
@@ -1,11 +1,13 @@
 from __future__ import absolute_import, unicode_literals
 
+import os
 from argparse import Namespace
 
 import pytest
 
 from virtualenv.config.ini import IniConfig
 from virtualenv.run import session_via_cli
+from virtualenv.util.path import Path
 
 
 def parse_cli(args):
@@ -33,3 +35,14 @@ def test_value_bad(monkeypatch, caplog, empty_conf):
     assert len(caplog.messages) == 1
     assert "env var VIRTUALENV_VERBOSE failed to convert" in caplog.messages[0]
     assert "invalid literal" in caplog.messages[0]
+
+
+def test_extra_search_dir(tmp_path, monkeypatch):
+    monkeypatch.chdir(tmp_path)
+    value = "a{}0{}b{}c".format(os.linesep, os.linesep, os.pathsep)
+    monkeypatch.setenv("VIRTUALENV_EXTRA_SEARCH_DIR", value)
+    (tmp_path / "a").mkdir()
+    (tmp_path / "b").mkdir()
+    (tmp_path / "c").mkdir()
+    result = parse_cli(["venv"])
+    assert result.seeder.extra_search_dir == [Path("a").resolve(), Path("b").resolve(), Path("c").resolve()]