Repository: pandas-dev/pandas

Issue:
String-based ExtensionArrays force cast to numpy in list based construction
#### Code Sample, a copy-pastable example if possible

```python
pd.Series(['E'], dtype=FletcherDType(pa.string())
```
#### Problem description

This leads to the following traceback but should actually create a Series object of a string-based type.

<details>

```

    def test_constructor_sanitation():
>       data = pd.Series(["E"], index=["F"], dtype=ArrowStringDtype())

pandas/tests/extension/arrow/test_string.py:11:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
pandas/core/series.py:310: in __init__
    data = sanitize_array(data, index, dtype, copy, raise_cast_failure=True)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

data = ['E'], index = Index(['F'], dtype='object'), dtype = <pandas.tests.extension.arrow.arrays.ArrowStringDtype object at 0x120913cf8>, copy = False, raise_cast_failure = True

    def sanitize_array(data, index, dtype=None, copy=False, raise_cast_failure=False):
        """
        Sanitize input data to an ndarray, copy if specified, coerce to the
        dtype if specified.
        """
        if dtype is not None:
            dtype = pandas_dtype(dtype)

        if isinstance(data, ma.MaskedArray):
            mask = ma.getmaskarray(data)
            if mask.any():
                data, fill_value = maybe_upcast(data, copy=True)
                data.soften_mask()  # set hardmask False if it was True
                data[mask] = fill_value
            else:
                data = data.copy()

        # extract ndarray or ExtensionArray, ensure we have no PandasArray
        data = extract_array(data, extract_numpy=True)

        # GH#846
        if isinstance(data, np.ndarray):

            if dtype is not None and is_float_dtype(data.dtype) and is_integer_dtype(dtype):
                # possibility of nan -> garbage
                try:
                    subarr = _try_cast(data, dtype, copy, True)
                except ValueError:
                    if copy:
                        subarr = data.copy()
                    else:
                        subarr = np.array(data, copy=False)
            else:
                # we will try to copy be-definition here
                subarr = _try_cast(data, dtype, copy, raise_cast_failure)

        elif isinstance(data, ABCExtensionArray):
            # it is already ensured above this is not a PandasArray
            subarr = data

            if dtype is not None:
                subarr = subarr.astype(dtype, copy=copy)
            elif copy:
                subarr = subarr.copy()
            return subarr

        elif isinstance(data, (list, tuple)) and len(data) > 0:
            if dtype is not None:
                try:
                    subarr = _try_cast(data, dtype, copy, raise_cast_failure)
                except Exception:
                    if raise_cast_failure:  # pragma: no cover
                        raise
                    subarr = np.array(data, dtype=object, copy=copy)
                    subarr = lib.maybe_convert_objects(subarr)

            else:
                subarr = maybe_convert_platform(data)

            subarr = maybe_cast_to_datetime(subarr, dtype)

        elif isinstance(data, range):
            # GH#16804
            arr = np.arange(data.start, data.stop, data.step, dtype="int64")
            subarr = _try_cast(arr, dtype, copy, raise_cast_failure)
        else:
            subarr = _try_cast(data, dtype, copy, raise_cast_failure)

        # scalar like, GH
        if getattr(subarr, "ndim", 0) == 0:
            if isinstance(data, list):  # pragma: no cover
                subarr = np.array(data, dtype=object)
            elif index is not None:
                value = data

                # figure out the dtype from the value (upcast if necessary)
                if dtype is None:
                    dtype, value = infer_dtype_from_scalar(value)
                else:
                    # need to possibly convert the value here
                    value = maybe_cast_to_datetime(value, dtype)

                subarr = construct_1d_arraylike_from_scalar(value, len(index), dtype)

            else:
                return subarr.item()

        # the result that we want
        elif subarr.ndim == 1:
            if index is not None:

                # a 1-element ndarray
                if len(subarr) != len(index) and len(subarr) == 1:
                    subarr = construct_1d_arraylike_from_scalar(
                        subarr[0], len(index), subarr.dtype
                    )

        elif subarr.ndim > 1:
            if isinstance(data, np.ndarray):
                raise Exception("Data must be 1-dimensional")
            else:
                subarr = com.asarray_tuplesafe(data, dtype=dtype)

        # This is to prevent mixed-type Series getting all casted to
        # NumPy string type, e.g. NaN --> '-1#IND'.
        if issubclass(subarr.dtype.type, str):
            import ipdb; ipdb.set_trace()
            # GH#16605
            # If not empty convert the data to dtype
            # GH#19853: If data is a scalar, subarr has already the result
>           if not lib.is_scalar(data):
E           TypeError: data type not understood

pandas/core/construction.py:478: TypeError
```
</details>

Problem exists in 0.25 and master, I'm working on a fix and will submit a PR later.


Discussion:

Resolving patch:
diff --git a/pandas/core/construction.py b/pandas/core/construction.py
--- a/pandas/core/construction.py
+++ b/pandas/core/construction.py
@@ -468,30 +468,27 @@ def sanitize_array(data, index, dtype=None, copy=False, raise_cast_failure=False
         else:
             subarr = com.asarray_tuplesafe(data, dtype=dtype)
 
-    # This is to prevent mixed-type Series getting all casted to
-    # NumPy string type, e.g. NaN --> '-1#IND'.
-    if issubclass(subarr.dtype.type, str):
-        # GH#16605
-        # If not empty convert the data to dtype
-        # GH#19853: If data is a scalar, subarr has already the result
-        if not lib.is_scalar(data):
-            if not np.all(isna(data)):
-                data = np.array(data, dtype=dtype, copy=False)
-            subarr = np.array(data, dtype=object, copy=copy)
-
-    if (
-        not (is_extension_array_dtype(subarr.dtype) or is_extension_array_dtype(dtype))
-        and is_object_dtype(subarr.dtype)
-        and not is_object_dtype(dtype)
-    ):
-        inferred = lib.infer_dtype(subarr, skipna=False)
-        if inferred == "period":
-            from pandas.core.arrays import period_array
+    if not (is_extension_array_dtype(subarr.dtype) or is_extension_array_dtype(dtype)):
+        # This is to prevent mixed-type Series getting all casted to
+        # NumPy string type, e.g. NaN --> '-1#IND'.
+        if issubclass(subarr.dtype.type, str):
+            # GH#16605
+            # If not empty convert the data to dtype
+            # GH#19853: If data is a scalar, subarr has already the result
+            if not lib.is_scalar(data):
+                if not np.all(isna(data)):
+                    data = np.array(data, dtype=dtype, copy=False)
+                subarr = np.array(data, dtype=object, copy=copy)
 
-            try:
-                subarr = period_array(subarr)
-            except IncompatibleFrequency:
-                pass
+        if is_object_dtype(subarr.dtype) and not is_object_dtype(dtype):
+            inferred = lib.infer_dtype(subarr, skipna=False)
+            if inferred == "period":
+                from pandas.core.arrays import period_array
+
+                try:
+                    subarr = period_array(subarr)
+                except IncompatibleFrequency:
+                    pass
 
     return subarr
 


Test patch:
diff --git a/pandas/tests/extension/arrow/bool.py b/pandas/tests/extension/arrow/arrays.py
similarity index 80%
rename from pandas/tests/extension/arrow/bool.py
rename to pandas/tests/extension/arrow/arrays.py
--- a/pandas/tests/extension/arrow/bool.py
+++ b/pandas/tests/extension/arrow/arrays.py
@@ -43,18 +43,27 @@ def _is_boolean(self):
         return True
 
 
-class ArrowBoolArray(ExtensionArray):
-    def __init__(self, values):
-        if not isinstance(values, pa.ChunkedArray):
-            raise ValueError
+@register_extension_dtype
+class ArrowStringDtype(ExtensionDtype):
 
-        assert values.type == pa.bool_()
-        self._data = values
-        self._dtype = ArrowBoolDtype()
+    type = str
+    kind = "U"
+    name = "arrow_string"
+    na_value = pa.NULL
+
+    @classmethod
+    def construct_from_string(cls, string):
+        if string == cls.name:
+            return cls()
+        else:
+            raise TypeError("Cannot construct a '{}' from '{}'".format(cls, string))
+
+    @classmethod
+    def construct_array_type(cls):
+        return ArrowStringArray
 
-    def __repr__(self):
-        return "ArrowBoolArray({})".format(repr(self._data))
 
+class ArrowExtensionArray(ExtensionArray):
     @classmethod
     def from_scalars(cls, values):
         arr = pa.chunked_array([pa.array(np.asarray(values))])
@@ -69,6 +78,9 @@ def from_array(cls, arr):
     def _from_sequence(cls, scalars, dtype=None, copy=False):
         return cls.from_scalars(scalars)
 
+    def __repr__(self):
+        return "{cls}({data})".format(cls=type(self).__name__, data=repr(self._data))
+
     def __getitem__(self, item):
         if pd.api.types.is_scalar(item):
             return self._data.to_pandas()[item]
@@ -142,3 +154,23 @@ def any(self, axis=0, out=None):
 
     def all(self, axis=0, out=None):
         return self._data.to_pandas().all()
+
+
+class ArrowBoolArray(ArrowExtensionArray):
+    def __init__(self, values):
+        if not isinstance(values, pa.ChunkedArray):
+            raise ValueError
+
+        assert values.type == pa.bool_()
+        self._data = values
+        self._dtype = ArrowBoolDtype()
+
+
+class ArrowStringArray(ArrowExtensionArray):
+    def __init__(self, values):
+        if not isinstance(values, pa.ChunkedArray):
+            raise ValueError
+
+        assert values.type == pa.string()
+        self._data = values
+        self._dtype = ArrowStringDtype()
diff --git a/pandas/tests/extension/arrow/test_bool.py b/pandas/tests/extension/arrow/test_bool.py
--- a/pandas/tests/extension/arrow/test_bool.py
+++ b/pandas/tests/extension/arrow/test_bool.py
@@ -7,7 +7,7 @@
 
 pytest.importorskip("pyarrow", minversion="0.10.0")
 
-from .bool import ArrowBoolArray, ArrowBoolDtype  # isort:skip
+from .arrays import ArrowBoolArray, ArrowBoolDtype  # isort:skip
 
 
 @pytest.fixture
diff --git a/pandas/tests/extension/arrow/test_string.py b/pandas/tests/extension/arrow/test_string.py
new file mode 100644
--- /dev/null
+++ b/pandas/tests/extension/arrow/test_string.py
@@ -0,0 +1,13 @@
+import pytest
+
+import pandas as pd
+
+pytest.importorskip("pyarrow", minversion="0.10.0")
+
+from .arrays import ArrowStringDtype  # isort:skip
+
+
+def test_constructor_from_list():
+    # GH 27673
+    result = pd.Series(["E"], dtype=ArrowStringDtype())
+    assert isinstance(result.dtype, ArrowStringDtype)