Repository: hgrecco/pint-pandas

Issue:
Support Pandas 1.0.0
Pandas has released version 1.0.0 Therefore we should be able to build against a stable API now.

Discussion:

Patch:
diff --git a/CHANGES b/CHANGES
index 0ae0912..6549c8b 100644
--- a/CHANGES
+++ b/CHANGES
@@ -4,7 +4,7 @@ pint-pandas Changelog
 0.2 (unreleased)
 ----------------
 
-- Partial support for pandas 1.2, which allows DataFrames with PintArrays to be plotted #53 #60
+- Support for pandas 1.2, which allows DataFrames with PintArrays to be plotted #53 #60 #61
 - Fixed bug which prevented creating a PintArray with offset units like temperature. #48
 - Fixed bug which caused Series.min() to throw an exception. #47
 
diff --git a/pint_pandas/pint_array.py b/pint_pandas/pint_array.py
index 4ad2404..42132bb 100644
--- a/pint_pandas/pint_array.py
+++ b/pint_pandas/pint_array.py
@@ -12,41 +12,15 @@ from pandas.api.extensions import (
     register_extension_dtype,
     register_series_accessor,
 )
-from pandas.api.types import is_integer, is_list_like
-from pandas.arrays import BooleanArray, IntegerArray
+from pandas.api.types import is_integer, is_list_like, is_object_dtype, is_string_dtype
 from pandas.compat import set_function_name
 from pandas.core.arrays.base import ExtensionOpsMixin
+from pandas.core.indexers import check_array_indexer
 from pint import compat, errors
 from pint.quantity import _Quantity
 from pint.unit import _Unit
 
 
-def convert_indexing_key(key):
-    if isinstance(key, BooleanArray):
-        return key.to_numpy(dtype=np.bool, na_value=False)
-
-    elif isinstance(key, IntegerArray):
-        if pd.isna(key).any():
-            raise ValueError(
-                "Cannot index with an integer indexer containing NA values"
-            )
-        return key.to_numpy(dtype=np.int)
-
-    elif isinstance(key, (list, tuple)):
-        if all(isinstance(val, bool) for val in key if val is not pd.NA):
-            return np.asarray(
-                [(False if val is pd.NA else val) for val in key], dtype=np.bool
-            )
-        if all(isinstance(val, int) for val in key if val is not pd.NA):
-            if any(val is pd.NA for val in key):
-                raise ValueError(
-                    "Cannot index with an integer indexer containing NA values"
-                )
-            return np.asarray([val for val in key if val is not pd.NA], dtype=np.int)
-
-    return key
-
-
 class PintType(ExtensionDtype):
     """
     A Pint duck-typed class, suitable for holding a quantity (with unit specified) dtype.
@@ -79,8 +53,6 @@ class PintType(ExtensionDtype):
 
         elif units is None:
             # empty constructor for pickle compat
-            # import pdb
-            # pdb.set_trace()
             return object.__new__(cls)
 
         if not isinstance(units, _Unit):
@@ -119,6 +91,10 @@ class PintType(ExtensionDtype):
         Strict construction from a string, raise a TypeError if not
         possible
         """
+        if not isinstance(string, str):
+            raise TypeError(
+                f"'construct_from_string' expects a string, got {type(string)}"
+            )
         if isinstance(string, str) and (
             string.startswith("pint[") or string.startswith("Pint[")
         ):
@@ -128,14 +104,21 @@ class PintType(ExtensionDtype):
                 return cls(units=string)
             except ValueError:
                 pass
-        # This else block may allow pd.Series([1,2],dtype="m")
-        #         else:
-        #             try:
-        #                 return cls(units=string)
-        #             except ValueError:
-        #                 pass
+        raise TypeError(f"Cannot construct a 'PintType' from '{string}'")
 
-        raise TypeError("Cannot construct a 'PintType' from '{}'".format(string))
+    @classmethod
+    def construct_from_quantity_string(cls, string):
+        """
+        Strict construction from a string, raise a TypeError if not
+        possible
+        """
+        if not isinstance(string, str):
+            raise TypeError(
+                f"'construct_from_quantity_string' expects a string, got {type(string)}"
+            )
+
+        quantity = cls.ureg.Quantity(string)
+        return cls(units=quantity.units)
 
     # def __unicode__(self):
     # return compat.text_type(self.name)
@@ -245,7 +228,7 @@ class PintArray(ExtensionArray, ExtensionOpsMixin):
         if is_integer(item):
             return self._data[item] * self.units
 
-        item = convert_indexing_key(item)
+        item = check_array_indexer(self, item)
 
         return self.__class__(self._data[item], self.dtype)
 
@@ -260,7 +243,7 @@ class PintArray(ExtensionArray, ExtensionOpsMixin):
         elif is_list_like(value) and isinstance(value[0], _Quantity):
             value = [item.to(self.units).magnitude for item in value]
 
-        key = convert_indexing_key(key)
+        key = check_array_indexer(self, key)
         try:
             self._data[key] = value
         except IndexError as e:
@@ -457,10 +440,14 @@ class PintArray(ExtensionArray, ExtensionOpsMixin):
         if isinstance(master_scalar, _Quantity):
             scalars = [quantify_nan(item) for item in scalars]
             scalars = [item.to(dtype.units).magnitude for item in scalars]
-        # import pdb
-        # pdb.set_trace()
         return cls(scalars, dtype=dtype, copy=copy)
 
+    @classmethod
+    def _from_sequence_of_strings(cls, scalars, dtype=None, copy=False):
+        if not dtype:
+            dtype = PintType.construct_from_quantity_string(scalars[0])
+        return cls._from_sequence([dtype.ureg.Quantity(x) for x in scalars])
+
     @classmethod
     def _from_factorized(cls, values, original):
         return cls(values, dtype=original.dtype)
@@ -513,6 +500,14 @@ class PintArray(ExtensionArray, ExtensionOpsMixin):
 
         return self._from_sequence(unique(self._data), dtype=self.dtype)
 
+    def __contains__(self, item) -> bool:
+        if not isinstance(item, _Quantity):
+            return False
+        elif pd.isna(item.magnitude):
+            return self.isna().any()
+        else:
+            return super().__contains__(item)
+
     @property
     def data(self):
         return self._data
@@ -587,7 +582,7 @@ class PintArray(ExtensionArray, ExtensionOpsMixin):
                 else:
                     return param
 
-            if isinstance(other, Series):
+            if isinstance(other, (Series, DataFrame)):
                 return NotImplemented
             lvalues = self.quantity
             other = validate_length(lvalues, other)
@@ -635,8 +630,14 @@ class PintArray(ExtensionArray, ExtensionOpsMixin):
         return cls(quantity.magnitude, quantity.units)
 
     def __array__(self, dtype=None, copy=False):
-        if dtype in [None, "object"]:
+        if dtype is None or is_object_dtype(dtype):
             return self._to_array_of_quantity(copy=copy)
+        if (isinstance(dtype, str) and dtype == "string") or isinstance(
+            dtype, pd.StringDtype
+        ):
+            return pd.array([str(x) for x in self.quantity], dtype=pd.StringDtype())
+        if is_string_dtype(dtype):
+            return np.array([str(x) for x in self.quantity], dtype=str)
         return np.array(self._data, dtype=dtype, copy=copy)
 
     def _to_array_of_quantity(self, copy=False):
diff --git a/pull_request_template.md b/pull_request_template.md
deleted file mode 100644
index 8ee5e75..0000000
--- a/pull_request_template.md
+++ /dev/null
@@ -1,5 +0,0 @@
-- [ ] Closes # (insert issue number)
-- [ ] Executed ``pre-commit run --all-files`` with no errors
-- [ ] The change is fully covered by automated unit tests
-- [ ] Documented in docs/ as appropriate
-- [ ] Added an entry to the CHANGES file


Test patch:
diff --git a/pint_pandas/testsuite/test_pandas_interface.py b/pint_pandas/testsuite/test_pandas_interface.py
index 404f6f4..a7f44cb 100644
--- a/pint_pandas/testsuite/test_pandas_interface.py
+++ b/pint_pandas/testsuite/test_pandas_interface.py
@@ -74,6 +74,15 @@ def data_repeated(data):
     yield gen
 
 
+@pytest.fixture(params=[None, lambda x: x])
+def sort_by_key(request):
+    """
+    Simple fixture for testing keys in sorting methods.
+    Tests None (no key) and the identity key.
+    """
+    return request.param
+
+
 @pytest.fixture
 def data_for_sorting():
     return ppi.PintArray.from_1darray_quantity([0.3, 10, -50] * ureg.centimeter)
@@ -192,22 +201,11 @@ def all_boolean_reductions(request):
 
 
 class TestCasting(base.BaseCastingTests):
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
-    def test_astype_str(self, data):
-        result = pd.Series(data[:5]).astype(str)
-        expected = pd.Series([str(x) for x in data[:5]], dtype=str)
-        self.assert_series_equal(result, expected)
-
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
-    def test_astype_string(self, data):
-        # GH-33465
-        result = pd.Series(data[:5]).astype("string")
-        expected = pd.Series([str(x) for x in data[:5]], dtype="string")
-        self.assert_series_equal(result, expected)
+    pass
 
 
 class TestConstructors(base.BaseConstructorsTests):
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
+    @pytest.mark.xfail(run=True, reason="__iter__ / __len__ issue")
     def test_series_constructor_no_data_with_index(self, dtype, na_value):
         result = pd.Series(index=[1, 2, 3], dtype=dtype)
         expected = pd.Series([na_value] * 3, index=[1, 2, 3], dtype=dtype)
@@ -218,13 +216,13 @@ class TestConstructors(base.BaseConstructorsTests):
         expected = pd.Series([], index=pd.Index([], dtype="object"), dtype=dtype)
         self.assert_series_equal(result, expected)
 
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
+    @pytest.mark.xfail(run=True, reason="__iter__ / __len__ issue")
     def test_series_constructor_scalar_na_with_index(self, dtype, na_value):
         result = pd.Series(na_value, index=[1, 2, 3], dtype=dtype)
         expected = pd.Series([na_value] * 3, index=[1, 2, 3], dtype=dtype)
         self.assert_series_equal(result, expected)
 
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
+    @pytest.mark.xfail(run=True, reason="__iter__ / __len__ issue")
     def test_series_constructor_scalar_with_index(self, data, dtype):
         scalar = data[0]
         result = pd.Series(scalar, index=[1, 2, 3], dtype=dtype)
@@ -237,54 +235,22 @@ class TestConstructors(base.BaseConstructorsTests):
 
 
 class TestDtype(base.BaseDtypeTests):
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
-    def test_construct_from_string_another_type_raises(self, dtype):
-        msg = f"Cannot construct a '{type(dtype).__name__}' from 'another_type'"
-        with pytest.raises(TypeError, match=msg):
-            type(dtype).construct_from_string("another_type")
-
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
-    def test_construct_from_string_wrong_type_raises(self, dtype):
-        with pytest.raises(
-            TypeError,
-            match="'construct_from_string' expects a string, got <class 'int'>",
-        ):
-            type(dtype).construct_from_string(0)
+    pass
 
 
 class TestGetitem(base.BaseGetitemTests):
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
     def test_getitem_mask_raises(self, data):
         mask = np.array([True, False])
-        with pytest.raises(IndexError):
+        msg = f"Boolean index has wrong length: 2 instead of {len(data)}"
+        with pytest.raises(IndexError, match=msg):
             data[mask]
 
         mask = pd.array(mask, dtype="boolean")
-        with pytest.raises(IndexError):
+        with pytest.raises(IndexError, match=msg):
             data[mask]
 
 
 class TestGroupby(base.BaseGroupbyTests):
-    @pytest.mark.xfail(
-        run=True, reason="pintarrays seem not to be numeric in one version of pd"
-    )
-    def test_in_numeric_groupby(self, data_for_grouping):
-        df = pd.DataFrame(
-            {
-                "A": [1, 1, 2, 2, 3, 3, 1, 4],
-                "B": data_for_grouping,
-                "C": [1, 1, 1, 1, 1, 1, 1, 1],
-            }
-        )
-        result = df.groupby("A").sum().columns
-
-        if data_for_grouping.dtype._is_numeric:
-            expected = pd.Index(["B", "C"])
-        else:
-            expected = pd.Index(["C"])
-
-        self.assert_index_equal(result, expected)
-
     @pytest.mark.xfail(run=True, reason="__iter__ / __len__ issue")
     def test_groupby_apply_identity(self, data_for_grouping):
         df = pd.DataFrame({"A": [1, 1, 2, 2, 3, 3, 1, 4], "B": data_for_grouping})
@@ -303,31 +269,7 @@ class TestGroupby(base.BaseGroupbyTests):
 
 
 class TestInterface(base.BaseInterfaceTests):
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
-    def test_contains(self, data, data_missing):
-        # GH-37867
-        # Tests for membership checks. Membership checks for nan-likes is tricky and
-        # the settled on rule is: `nan_like in arr` is True if nan_like is
-        # arr.dtype.na_value and arr.isna().any() is True. Else the check returns False.
-
-        na_value = data.dtype.na_value
-        # ensure data without missing values
-        data = data[~data.isna()]
-
-        # first elements are non-missing
-        assert data[0] in data
-        assert data_missing[0] in data_missing
-
-        # check the presence of na_value
-        assert na_value in data_missing
-        assert na_value not in data
-
-        # the data can never contain other nan-likes than na_value
-        # for na_value_obj in tm.NULL_OBJECTS:
-        #     if na_value_obj is na_value:
-        #         continue
-        #     assert na_value_obj not in data
-        #     assert na_value_obj not in data_missing
+    pass
 
 
 class TestMethods(base.BaseMethodsTests):
@@ -439,7 +381,6 @@ class TestMethods(base.BaseMethodsTests):
             expected = expected.to_frame(name="a")
         self.assert_equal(result, expected)
 
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
     @pytest.mark.parametrize("ascending", [True, False])
     def test_sort_values(self, data_for_sorting, ascending, sort_by_key):
         ser = pd.Series(data_for_sorting)
@@ -450,7 +391,6 @@ class TestMethods(base.BaseMethodsTests):
 
         self.assert_series_equal(result, expected)
 
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
     @pytest.mark.parametrize("ascending", [True, False])
     def test_sort_values_missing(
         self, data_missing_for_sorting, ascending, sort_by_key
@@ -525,6 +465,7 @@ class TestArithmeticOps(base.BaseArithmeticOpsTests):
         self._check_divmod_op(s, divmod, 1 * ureg.Mm)
         self._check_divmod_op(1 * ureg.Mm, ops.rdivmod, s)
 
+    @pytest.mark.xfail(run=True, reason="Test is deleted in pd 1.3, pd GH #39386")
     def test_error(self, data, all_arithmetic_operators):
         # invalid ops
 
@@ -560,7 +501,6 @@ class TestArithmeticOps(base.BaseArithmeticOpsTests):
         with pytest.raises(ValueError):
             opa(np.arange(len(s)).reshape(-1, len(s)))
 
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
     @pytest.mark.parametrize("box", [pd.Series, pd.DataFrame])
     def test_direct_arith_with_ndframe_returns_not_implemented(self, data, box):
         # EAs should return NotImplemented for ops with Series/DataFrame
@@ -597,7 +537,6 @@ class TestComparisonOps(base.BaseComparisonOpsTests):
         other = data
         self._compare_other(s, data, op_name, other)
 
-    @pytest.mark.xfail(run=True, reason="TODO: fix pd 1.2 tests")
     @pytest.mark.parametrize("box", [pd.Series, pd.DataFrame])
     def test_direct_arith_with_ndframe_returns_not_implemented(self, data, box):
         # EAs should return NotImplemented for ops with Series/DataFrame
@@ -623,6 +562,14 @@ class TestOpsUtil(base.BaseOpsUtil):
     pass
 
 
+class TestParsing(base.BaseParsingTests):
+    pass
+
+
+class TestPrinting(base.BasePrintingTests):
+    pass
+
+
 class TestMissing(base.BaseMissingTests):
     @pytest.mark.xfail(run=True, reason="__iter__ / __len__ issue")
     def test_fillna_scalar(self, data_missing):