From 8d8df825e1e327cc6bdc413cb3d5620862b67b79 Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Sat, 14 Sep 2019 15:17:15 +0100 Subject: [PATCH 01/12] Operate on categories --- pandas/core/indexes/accessors.py | 9 +++++---- pandas/tests/indexes/test_category.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/pandas/core/indexes/accessors.py b/pandas/core/indexes/accessors.py index cc8ecc0e64684..956818870a053 100644 --- a/pandas/core/indexes/accessors.py +++ b/pandas/core/indexes/accessors.py @@ -16,7 +16,6 @@ from pandas.core.dtypes.generic import ABCSeries from pandas.core.accessor import PandasDelegate, delegate_names -from pandas.core.algorithms import take_1d from pandas.core.arrays import DatetimeArray, PeriodArray, TimedeltaArray from pandas.core.base import NoNewAttributesMixin, PandasObject from pandas.core.indexes.datetimes import DatetimeIndex @@ -75,9 +74,7 @@ def _delegate_property_get(self, name): result = np.asarray(result) - # blow up if we operate on categories if self.orig is not None: - result = take_1d(result, self.orig.cat.codes) index = self.orig.index else: index = self._parent.index @@ -324,7 +321,11 @@ def __new__(cls, data): orig = data if is_categorical_dtype(data) else None if orig is not None: - data = Series(orig.values.categories, name=orig.name, copy=False) + data = Series( + orig.values.astype(orig.values.categories.dtype), + name=orig.name, + copy=False, + ) if is_datetime64_dtype(data.dtype): return DatetimeProperties(data, orig) diff --git a/pandas/tests/indexes/test_category.py b/pandas/tests/indexes/test_category.py index 61d9d1d70c360..0f18f2772277a 100644 --- a/pandas/tests/indexes/test_category.py +++ b/pandas/tests/indexes/test_category.py @@ -1125,3 +1125,26 @@ def test_engine_type(self, dtype, engine_type): ci.values._codes = ci.values._codes.astype("int64") assert np.issubdtype(ci.codes.dtype, dtype) assert isinstance(ci._engine, engine_type) + + def test_dt_tz_localize(self, tz_aware_fixture): + # GH 27952 + tz = tz_aware_fixture + datetimes = pd.Series( + ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns]" + ) + categorical = datetimes.astype("category") + result = categorical.dt.tz_localize(tz) + expected = datetimes.dt.tz_localize(tz) + tm.assert_series_equal(result, expected) + + @pytest.mark.parametrize("accessor", [("year"), ("month"), ("day")]) + def test_dt_other_accessors(self, accessor): + # GH 27952 + tz = None + datetimes = pd.Series( + ["2018-01-01", "2018-01-01", "2019-01-02"], dtype="datetime64[ns]" + ) + categorical = datetimes.astype("category") + result = getattr(categorical.dt, accessor) + expected = getattr(datetimes.dt, accessor) + tm.assert_series_equal(result, expected) From 98b92cce09ff83d51f1dff9b5f00f71ac714de7c Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Sat, 14 Sep 2019 15:58:19 +0100 Subject: [PATCH 02/12] remove unused local variable --- pandas/tests/indexes/test_category.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pandas/tests/indexes/test_category.py b/pandas/tests/indexes/test_category.py index 0f18f2772277a..d3195b30f8979 100644 --- a/pandas/tests/indexes/test_category.py +++ b/pandas/tests/indexes/test_category.py @@ -1140,7 +1140,6 @@ def test_dt_tz_localize(self, tz_aware_fixture): @pytest.mark.parametrize("accessor", [("year"), ("month"), ("day")]) def test_dt_other_accessors(self, accessor): # GH 27952 - tz = None datetimes = pd.Series( ["2018-01-01", "2018-01-01", "2019-01-02"], dtype="datetime64[ns]" ) From 1c15441927b47de9fec6204f17bd36b9355dc77d Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Mon, 16 Sep 2019 16:03:35 +0100 Subject: [PATCH 03/12] Use __array__ instead of to_list, set dtype in Series --- pandas/core/indexes/accessors.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pandas/core/indexes/accessors.py b/pandas/core/indexes/accessors.py index 956818870a053..2e6be400b9d29 100644 --- a/pandas/core/indexes/accessors.py +++ b/pandas/core/indexes/accessors.py @@ -322,9 +322,10 @@ def __new__(cls, data): orig = data if is_categorical_dtype(data) else None if orig is not None: data = Series( - orig.values.astype(orig.values.categories.dtype), + orig.values.__array__(), name=orig.name, copy=False, + dtype=orig.values.categories.dtype, ) if is_datetime64_dtype(data.dtype): From 44d22a79a7418f5f1aa7905c38bd28a8dd806925 Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Mon, 16 Sep 2019 23:39:02 +0100 Subject: [PATCH 04/12] Add tz_convert test --- pandas/core/indexes/accessors.py | 2 +- pandas/tests/indexes/test_category.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pandas/core/indexes/accessors.py b/pandas/core/indexes/accessors.py index 2e6be400b9d29..3447413b4c321 100644 --- a/pandas/core/indexes/accessors.py +++ b/pandas/core/indexes/accessors.py @@ -322,7 +322,7 @@ def __new__(cls, data): orig = data if is_categorical_dtype(data) else None if orig is not None: data = Series( - orig.values.__array__(), + np.asarray(orig.values), name=orig.name, copy=False, dtype=orig.values.categories.dtype, diff --git a/pandas/tests/indexes/test_category.py b/pandas/tests/indexes/test_category.py index d3195b30f8979..42f8e3dcd8d5d 100644 --- a/pandas/tests/indexes/test_category.py +++ b/pandas/tests/indexes/test_category.py @@ -1137,7 +1137,18 @@ def test_dt_tz_localize(self, tz_aware_fixture): expected = datetimes.dt.tz_localize(tz) tm.assert_series_equal(result, expected) - @pytest.mark.parametrize("accessor", [("year"), ("month"), ("day")]) + def test_dt_tz_convert(self, tz_aware_fixture): + # GH 27952 + tz = tz_aware_fixture + datetimes = pd.Series( + ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns, MET]" + ) + categorical = datetimes.astype("category") + result = categorical.dt.tz_convert(tz) + expected = datetimes.dt.tz_convert(tz) + tm.assert_series_equal(result, expected) + + @pytest.mark.parametrize("accessor", ["year", "month", "day"]) def test_dt_other_accessors(self, accessor): # GH 27952 datetimes = pd.Series( From 3126408be293d8bcb2d848119bb175ed73ae572a Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Fri, 20 Sep 2019 09:32:38 +0100 Subject: [PATCH 05/12] Rephrase whatsnew entry, move tests --- doc/source/whatsnew/v1.0.0.rst | 4 ++ pandas/tests/indexes/test_category.py | 33 ----------- pandas/tests/series/test_datetime_values.py | 66 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/doc/source/whatsnew/v1.0.0.rst b/doc/source/whatsnew/v1.0.0.rst index fa1669b1f3343..0d7eb4a650b68 100644 --- a/doc/source/whatsnew/v1.0.0.rst +++ b/doc/source/whatsnew/v1.0.0.rst @@ -288,6 +288,10 @@ Categorical - :meth:`Categorical.searchsorted` and :meth:`CategoricalIndex.searchsorted` now work on unordered categoricals also (:issue:`21667`) - Added test to assert roundtripping to parquet with :func:`DataFrame.to_parquet` or :func:`read_parquet` will preserve Categorical dtypes for string types (:issue:`27955`) - Changed the error message in :meth:`Categorical.remove_categories` to always show the invalid removals as a set (:issue:`28669`) +- Using date accessors on a categorical dtyped series of datetimes was not returning a Series (or DataFrame) of the + same type as if one used the .str. / .dt. on a Series of that type, e.g. when accessing :meth:`Series.dt.tz_localize` on a + :class:`Categorical` with duplicate entries, the accessor was skipping duplicates (:issue: `27952`) +- Datetimelike diff --git a/pandas/tests/indexes/test_category.py b/pandas/tests/indexes/test_category.py index 42f8e3dcd8d5d..61d9d1d70c360 100644 --- a/pandas/tests/indexes/test_category.py +++ b/pandas/tests/indexes/test_category.py @@ -1125,36 +1125,3 @@ def test_engine_type(self, dtype, engine_type): ci.values._codes = ci.values._codes.astype("int64") assert np.issubdtype(ci.codes.dtype, dtype) assert isinstance(ci._engine, engine_type) - - def test_dt_tz_localize(self, tz_aware_fixture): - # GH 27952 - tz = tz_aware_fixture - datetimes = pd.Series( - ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns]" - ) - categorical = datetimes.astype("category") - result = categorical.dt.tz_localize(tz) - expected = datetimes.dt.tz_localize(tz) - tm.assert_series_equal(result, expected) - - def test_dt_tz_convert(self, tz_aware_fixture): - # GH 27952 - tz = tz_aware_fixture - datetimes = pd.Series( - ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns, MET]" - ) - categorical = datetimes.astype("category") - result = categorical.dt.tz_convert(tz) - expected = datetimes.dt.tz_convert(tz) - tm.assert_series_equal(result, expected) - - @pytest.mark.parametrize("accessor", ["year", "month", "day"]) - def test_dt_other_accessors(self, accessor): - # GH 27952 - datetimes = pd.Series( - ["2018-01-01", "2018-01-01", "2019-01-02"], dtype="datetime64[ns]" - ) - categorical = datetimes.astype("category") - result = getattr(categorical.dt, accessor) - expected = getattr(datetimes.dt, accessor) - tm.assert_series_equal(result, expected) diff --git a/pandas/tests/series/test_datetime_values.py b/pandas/tests/series/test_datetime_values.py index 1346f2fd57f10..ed7a5bd01e4e6 100644 --- a/pandas/tests/series/test_datetime_values.py +++ b/pandas/tests/series/test_datetime_values.py @@ -344,6 +344,72 @@ def test_dt_namespace_accessor_categorical(self): expected = Series([2017, 2017, 2018, 2018], name="foo") tm.assert_series_equal(result, expected) + def test_dt_tz_localize(self, tz_aware_fixture): + # GH 27952 + tz = tz_aware_fixture + datetimes = pd.Series( + ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns]" + ) + categorical = datetimes.astype("category") + result = categorical.dt.tz_localize(tz) + expected = datetimes.dt.tz_localize(tz) + tm.assert_series_equal(result, expected) + + def test_dt_tz_convert(self, tz_aware_fixture): + # GH 27952 + tz = tz_aware_fixture + datetimes = pd.Series( + ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns, MET]" + ) + categorical = datetimes.astype("category") + result = categorical.dt.tz_convert(tz) + expected = datetimes.dt.tz_convert(tz) + tm.assert_series_equal(result, expected) + + @pytest.mark.parametrize("accessor", ["year", "month", "day"]) + def test_dt_other_accessors(self, accessor): + # GH 27952 + datetimes = pd.Series( + ["2018-01-01", "2018-01-01", "2019-01-02"], dtype="datetime64[ns]" + ) + categorical = datetimes.astype("category") + result = getattr(categorical.dt, accessor) + expected = getattr(datetimes.dt, accessor) + tm.assert_series_equal(result, expected) + + def test_dt_tz_localize(self, tz_aware_fixture): + # GH 27952 + tz = tz_aware_fixture + datetimes = pd.Series( + ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns]" + ) + categorical = datetimes.astype("category") + result = categorical.dt.tz_localize(tz) + expected = datetimes.dt.tz_localize(tz) + tm.assert_series_equal(result, expected) + + def test_dt_tz_convert(self, tz_aware_fixture): + # GH 27952 + tz = tz_aware_fixture + datetimes = pd.Series( + ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns, MET]" + ) + categorical = datetimes.astype("category") + result = categorical.dt.tz_convert(tz) + expected = datetimes.dt.tz_convert(tz) + tm.assert_series_equal(result, expected) + + @pytest.mark.parametrize("accessor", ["year", "month", "day"]) + def test_dt_other_accessors(self, accessor): + # GH 27952 + datetimes = pd.Series( + ["2018-01-01", "2018-01-01", "2019-01-02"], dtype="datetime64[ns]" + ) + categorical = datetimes.astype("category") + result = getattr(categorical.dt, accessor) + expected = getattr(datetimes.dt, accessor) + tm.assert_series_equal(result, expected) + def test_dt_accessor_no_new_attributes(self): # https://github.com/pandas-dev/pandas/issues/10673 s = Series(date_range("20130101", periods=5, freq="D")) From c15b457599868119dcaec68e2da4a572e0f2ef84 Mon Sep 17 00:00:00 2001 From: Marco Gorelli Date: Fri, 20 Sep 2019 10:04:22 +0100 Subject: [PATCH 06/12] Remove duplicated tests --- pandas/tests/series/test_datetime_values.py | 33 --------------------- 1 file changed, 33 deletions(-) diff --git a/pandas/tests/series/test_datetime_values.py b/pandas/tests/series/test_datetime_values.py index ed7a5bd01e4e6..0f5ead64ace52 100644 --- a/pandas/tests/series/test_datetime_values.py +++ b/pandas/tests/series/test_datetime_values.py @@ -377,39 +377,6 @@ def test_dt_other_accessors(self, accessor): expected = getattr(datetimes.dt, accessor) tm.assert_series_equal(result, expected) - def test_dt_tz_localize(self, tz_aware_fixture): - # GH 27952 - tz = tz_aware_fixture - datetimes = pd.Series( - ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns]" - ) - categorical = datetimes.astype("category") - result = categorical.dt.tz_localize(tz) - expected = datetimes.dt.tz_localize(tz) - tm.assert_series_equal(result, expected) - - def test_dt_tz_convert(self, tz_aware_fixture): - # GH 27952 - tz = tz_aware_fixture - datetimes = pd.Series( - ["2019-01-01", "2019-01-01", "2019-01-02"], dtype="datetime64[ns, MET]" - ) - categorical = datetimes.astype("category") - result = categorical.dt.tz_convert(tz) - expected = datetimes.dt.tz_convert(tz) - tm.assert_series_equal(result, expected) - - @pytest.mark.parametrize("accessor", ["year", "month", "day"]) - def test_dt_other_accessors(self, accessor): - # GH 27952 - datetimes = pd.Series( - ["2018-01-01", "2018-01-01", "2019-01-02"], dtype="datetime64[ns]" - ) - categorical = datetimes.astype("category") - result = getattr(categorical.dt, accessor) - expected = getattr(datetimes.dt, accessor) - tm.assert_series_equal(result, expected) - def test_dt_accessor_no_new_attributes(self): # https://github.com/pandas-dev/pandas/issues/10673 s = Series(date_range("20130101", periods=5, freq="D")) From 3ac3e73900474248c67f143bc780a3a597657b91 Mon Sep 17 00:00:00 2001 From: Marco Gorelli Date: Tue, 24 Sep 2019 09:21:51 +0100 Subject: [PATCH 07/12] Rephrase whatnew entry --- doc/source/whatsnew/v1.0.0.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/source/whatsnew/v1.0.0.rst b/doc/source/whatsnew/v1.0.0.rst index 0d7eb4a650b68..30e127259d61e 100644 --- a/doc/source/whatsnew/v1.0.0.rst +++ b/doc/source/whatsnew/v1.0.0.rst @@ -290,6 +290,8 @@ Categorical - Changed the error message in :meth:`Categorical.remove_categories` to always show the invalid removals as a set (:issue:`28669`) - Using date accessors on a categorical dtyped series of datetimes was not returning a Series (or DataFrame) of the same type as if one used the .str. / .dt. on a Series of that type, e.g. when accessing :meth:`Series.dt.tz_localize` on a +- Using date accessors on a categorical dtyped :class:`Series` of datetimes was not returning an object of the + same type as if one used the :meth:`.str.` / :meth:`.dt.` on a :class:`Series` of that type. E.g. when accessing :meth:`Series.dt.tz_localize` on a :class:`Categorical` with duplicate entries, the accessor was skipping duplicates (:issue: `27952`) - From dc9ed3d6dc55245b9a8b02e7675962321bb8da14 Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Sat, 2 Nov 2019 09:47:17 +0000 Subject: [PATCH 08/12] Fix conflict --- doc/source/whatsnew/v1.0.0.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/source/whatsnew/v1.0.0.rst b/doc/source/whatsnew/v1.0.0.rst index 30e127259d61e..5d8ffb4461166 100644 --- a/doc/source/whatsnew/v1.0.0.rst +++ b/doc/source/whatsnew/v1.0.0.rst @@ -293,6 +293,9 @@ Categorical - Using date accessors on a categorical dtyped :class:`Series` of datetimes was not returning an object of the same type as if one used the :meth:`.str.` / :meth:`.dt.` on a :class:`Series` of that type. E.g. when accessing :meth:`Series.dt.tz_localize` on a :class:`Categorical` with duplicate entries, the accessor was skipping duplicates (:issue: `27952`) +- Using date accessors on a categorical dtyped :class:`Series` of datetimes was not returning the same object that would + be returned if one used the :meth:`.str.` / :meth:`.dt.` on a :class:`Series` of that type. E.g. when accessing :meth:`Series.dt.tz_localize` on a + :class:`Categorical` with duplicate entries, the accessor would skip duplicates (:issue: `27952`) - From 4d0320c3878cfac4217b744269492aba33895976 Mon Sep 17 00:00:00 2001 From: Marco Gorelli Date: Tue, 8 Oct 2019 17:04:44 +0100 Subject: [PATCH 09/12] Remove np.asarray, as #28762 has been fixed --- pandas/core/indexes/accessors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/core/indexes/accessors.py b/pandas/core/indexes/accessors.py index 3447413b4c321..14a4331a84e05 100644 --- a/pandas/core/indexes/accessors.py +++ b/pandas/core/indexes/accessors.py @@ -322,7 +322,7 @@ def __new__(cls, data): orig = data if is_categorical_dtype(data) else None if orig is not None: data = Series( - np.asarray(orig.values), + orig.values, name=orig.name, copy=False, dtype=orig.values.categories.dtype, From 9a7f9118ee45beac7cae7bcbbdaa15341275cd97 Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Sat, 2 Nov 2019 10:01:34 +0000 Subject: [PATCH 10/12] Fix rebase --- doc/source/whatsnew/v1.0.0.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/doc/source/whatsnew/v1.0.0.rst b/doc/source/whatsnew/v1.0.0.rst index 5d8ffb4461166..a2a556f22790a 100644 --- a/doc/source/whatsnew/v1.0.0.rst +++ b/doc/source/whatsnew/v1.0.0.rst @@ -288,15 +288,9 @@ Categorical - :meth:`Categorical.searchsorted` and :meth:`CategoricalIndex.searchsorted` now work on unordered categoricals also (:issue:`21667`) - Added test to assert roundtripping to parquet with :func:`DataFrame.to_parquet` or :func:`read_parquet` will preserve Categorical dtypes for string types (:issue:`27955`) - Changed the error message in :meth:`Categorical.remove_categories` to always show the invalid removals as a set (:issue:`28669`) -- Using date accessors on a categorical dtyped series of datetimes was not returning a Series (or DataFrame) of the - same type as if one used the .str. / .dt. on a Series of that type, e.g. when accessing :meth:`Series.dt.tz_localize` on a - Using date accessors on a categorical dtyped :class:`Series` of datetimes was not returning an object of the same type as if one used the :meth:`.str.` / :meth:`.dt.` on a :class:`Series` of that type. E.g. when accessing :meth:`Series.dt.tz_localize` on a :class:`Categorical` with duplicate entries, the accessor was skipping duplicates (:issue: `27952`) -- Using date accessors on a categorical dtyped :class:`Series` of datetimes was not returning the same object that would - be returned if one used the :meth:`.str.` / :meth:`.dt.` on a :class:`Series` of that type. E.g. when accessing :meth:`Series.dt.tz_localize` on a - :class:`Categorical` with duplicate entries, the accessor would skip duplicates (:issue: `27952`) -- Datetimelike From 395f04b2fe47f9f8a653ce668dc234625c6bcce8 Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Sat, 2 Nov 2019 16:21:28 +0000 Subject: [PATCH 11/12] Stylistic changes --- pandas/core/indexes/accessors.py | 2 +- pandas/tests/series/test_datetime_values.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/core/indexes/accessors.py b/pandas/core/indexes/accessors.py index 14a4331a84e05..e8d2ba85e08a6 100644 --- a/pandas/core/indexes/accessors.py +++ b/pandas/core/indexes/accessors.py @@ -322,7 +322,7 @@ def __new__(cls, data): orig = data if is_categorical_dtype(data) else None if orig is not None: data = Series( - orig.values, + orig.array, name=orig.name, copy=False, dtype=orig.values.categories.dtype, diff --git a/pandas/tests/series/test_datetime_values.py b/pandas/tests/series/test_datetime_values.py index 0f5ead64ace52..84d7f19e530d2 100644 --- a/pandas/tests/series/test_datetime_values.py +++ b/pandas/tests/series/test_datetime_values.py @@ -344,7 +344,7 @@ def test_dt_namespace_accessor_categorical(self): expected = Series([2017, 2017, 2018, 2018], name="foo") tm.assert_series_equal(result, expected) - def test_dt_tz_localize(self, tz_aware_fixture): + def test_dt_tz_localize_categorical(self, tz_aware_fixture): # GH 27952 tz = tz_aware_fixture datetimes = pd.Series( From 44a63730c3bb91d90cf42ccd110394934eeab243 Mon Sep 17 00:00:00 2001 From: MarcoGorelli Date: Sat, 2 Nov 2019 16:23:16 +0000 Subject: [PATCH 12/12] Finish stylistic changes --- pandas/tests/series/test_datetime_values.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/tests/series/test_datetime_values.py b/pandas/tests/series/test_datetime_values.py index 84d7f19e530d2..9304e1c4fc157 100644 --- a/pandas/tests/series/test_datetime_values.py +++ b/pandas/tests/series/test_datetime_values.py @@ -355,7 +355,7 @@ def test_dt_tz_localize_categorical(self, tz_aware_fixture): expected = datetimes.dt.tz_localize(tz) tm.assert_series_equal(result, expected) - def test_dt_tz_convert(self, tz_aware_fixture): + def test_dt_tz_convert_categorical(self, tz_aware_fixture): # GH 27952 tz = tz_aware_fixture datetimes = pd.Series( @@ -367,7 +367,7 @@ def test_dt_tz_convert(self, tz_aware_fixture): tm.assert_series_equal(result, expected) @pytest.mark.parametrize("accessor", ["year", "month", "day"]) - def test_dt_other_accessors(self, accessor): + def test_dt_other_accessors_categorical(self, accessor): # GH 27952 datetimes = pd.Series( ["2018-01-01", "2018-01-01", "2019-01-02"], dtype="datetime64[ns]"