Repository: pandas-dev/pandas

Issue:
`series.dt.tz_localize()` on Categorical operates on categories, not values
#### Code Sample, a copy-pastable example if possible

```python
datetimes = pd.Series(['2019-01-01', '2019-01-01', '2019-01-02'], dtype='datetime64[ns]')
categorical = datetimes.astype('category')
categorical.dt.tz_localize(None)
```

Produces:

```
0   2019-01-01
1   2019-01-02
dtype: datetime64[ns]
```

#### Problem description

`.dt.tz_localize()` is operating on `categorical.cat.categories`. It should be operating on `categorical.astype('datetime64[ns]').values`. This is just plain wrong.

#### Expected Output

According to [Categorical docs](https://pandas.pydata.org/pandas-docs/stable/user_guide/categorical.html#string-and-datetime-accessors), "The returned Series (or DataFrame) is of the same type as if you used the .str.<method> / .dt.<method> on a Series of that type (and not of type category!).". So I think the expected value to be:

```
>>> datetimes.dt.tz_localize(None)
0   2019-01-01
1   2019-01-01
2   2019-01-02
dtype: datetime64[ns]
```

#### Output of ``pd.show_versions()``

<details>
INSTALLED VERSIONS
------------------
commit           : None
python           : 3.7.2.final.0
python-bits      : 64
OS               : Linux
OS-release       : 5.2.8-200.fc30.x86_64
machine          : x86_64
processor        :
byteorder        : little
LC_ALL           : None
LANG             : C.UTF-8
LOCALE           : en_US.UTF-8

pandas           : 0.25.0
numpy            : 1.17.0
pytz             : 2019.2
dateutil         : 2.8.0
pip              : 19.0.2
setuptools       : 40.8.0
Cython           : None
pytest           : None
hypothesis       : None
sphinx           : None
blosc            : None
feather          : None
xlsxwriter       : None
lxml.etree       : 4.3.0
html5lib         : 1.0.1
pymysql          : None
psycopg2         : 2.8.3 (dt dec pq3 ext lo64)
jinja2           : None
IPython          : None
pandas_datareader: None
bs4              : 4.7.1
bottleneck       : None
fastparquet      : 0.3.2
gcsfs            : None
lxml.etree       : 4.3.0
matplotlib       : None
numexpr          : None
odfpy            : None
openpyxl         : None
pandas_gbq       : None
pyarrow          : None
pytables         : None
s3fs             : None
scipy            : None
sqlalchemy       : None
tables           : None
xarray           : None
xlrd             : 1.2.0
xlwt             : None
xlsxwriter       : None
</details>



Discussion:
The line that needs fixing is here:

https://github.com/pandas-dev/pandas/blob/9f93d57025d7b906de6956f287b71cd18c17b13d/pandas/core/indexes/accessors.py#L327

PR's welcome!

Resolving patch:
diff --git a/pandas/core/indexes/accessors.py b/pandas/core/indexes/accessors.py
--- 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,12 @@ 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.array,
+                name=orig.name,
+                copy=False,
+                dtype=orig.values.categories.dtype,
+            )
 
         if is_datetime64_dtype(data.dtype):
             return DatetimeProperties(data, orig)


Test patch:
diff --git a/pandas/tests/series/test_datetime_values.py b/pandas/tests/series/test_datetime_values.py
--- a/pandas/tests/series/test_datetime_values.py
+++ b/pandas/tests/series/test_datetime_values.py
@@ -344,6 +344,39 @@ 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_categorical(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_categorical(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_categorical(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"))