Repository: pandas-dev/pandas
Pull request: BUG: PeriodDtype incorrectly caching different DateOffset freqs
- [x] closes #24121 (Replace xxxx with the GitHub issue number)
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [ ] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.


Problem statement:
BUG - PeriodIndex.freq.normalize option gets cached per process
It seems that whatever normalize option is first called for a PeriodIndex freq it is used for the rest of the process (on a per offset type basis). No matter whether the freq is explicitly overwritten with a freq using a different normalize option. This is NOT the case for the offset kwds.

    >>> pd.PeriodIndex(['19910905'], freq=pd.offsets.YearEnd(normalize=True)).freq.normalize
    True
    >>> pd.PeriodIndex(['19910905'], freq=pd.offsets.YearEnd(normalize=False)).freq.normalize
    True

Restart the process

    >>> pd.PeriodIndex(['19910905'], freq=pd.offsets.YearEnd(normalize=False)).freq.normalize
    False
    >>> pd.PeriodIndex(['19910905'], freq=pd.offsets.YearEnd(normalize=True)).freq.normalize
    False

Hints from issue discussion:

Commit message: Change typing

Patch to review:
diff --git a/doc/source/whatsnew/v2.1.0.rst b/doc/source/whatsnew/v2.1.0.rst
index 644a7e86ec429..fac422b7eb4fe 100644
--- a/doc/source/whatsnew/v2.1.0.rst
+++ b/doc/source/whatsnew/v2.1.0.rst
@@ -374,12 +374,12 @@ Period
 ^^^^^^
 - :meth:`PeriodIndex.map` with ``na_action="ignore"`` now works as expected (:issue:`51644`)
 - Bug in :class:`PeriodDtype` constructor failing to raise ``TypeError`` when no argument is passed or when ``None`` is passed (:issue:`27388`)
+- Bug in :class:`PeriodDtype` constructor incorrectly returning the same ``normalize`` for different :class:`DateOffset` ``freq`` inputs (:issue:`24121`)
 - Bug in :class:`PeriodDtype` constructor raising ``ValueError`` instead of ``TypeError`` when an invalid type is passed (:issue:`51790`)
 - Bug in :func:`read_csv` not processing empty strings as a null value, with ``engine="pyarrow"`` (:issue:`52087`)
 - Bug in :func:`read_csv` returning ``object`` dtype columns instead of ``float64`` dtype columns with ``engine="pyarrow"`` for columns that are all null with ``engine="pyarrow"`` (:issue:`52087`)
 - Bug in :meth:`arrays.PeriodArray.map` and :meth:`PeriodIndex.map`, where the supplied callable operated array-wise instead of element-wise (:issue:`51977`)
 - Bug in incorrectly allowing construction of :class:`Period` or :class:`PeriodDtype` with :class:`CustomBusinessDay` freq; use :class:`BusinessDay` instead (:issue:`52534`)
--
 
 Plotting
 ^^^^^^^^
diff --git a/pandas/core/dtypes/dtypes.py b/pandas/core/dtypes/dtypes.py
index 014d7a4c5a330..d511bf01c2d91 100644
--- a/pandas/core/dtypes/dtypes.py
+++ b/pandas/core/dtypes/dtypes.py
@@ -899,7 +899,7 @@ class PeriodDtype(PeriodDtypeBase, PandasExtensionDtype):
     num = 102
     _metadata = ("freq",)
     _match = re.compile(r"(P|p)eriod\[(?P<freq>.+)\]")
-    _cache_dtypes: dict[str_type, PandasExtensionDtype] = {}
+    _cache_dtypes: dict[int, PandasExtensionDtype] = {}
     __hash__ = PeriodDtypeBase.__hash__
     _freq: BaseOffset
 
@@ -916,12 +916,12 @@ def __new__(cls, freq):
             freq = cls._parse_dtype_strict(freq)
 
         try:
-            return cls._cache_dtypes[freq.freqstr]
+            return cls._cache_dtypes[hash(freq)]
         except KeyError:
             dtype_code = freq._period_dtype_code
             u = PeriodDtypeBase.__new__(cls, dtype_code, freq.n)
             u._freq = freq
-            cls._cache_dtypes[freq.freqstr] = u
+            cls._cache_dtypes[hash(freq)] = u
             return u
 
     def __reduce__(self):
diff --git a/pandas/tests/arrays/test_period.py b/pandas/tests/arrays/test_period.py
index d1e954bc2ebe2..aa0a13670a525 100644
--- a/pandas/tests/arrays/test_period.py
+++ b/pandas/tests/arrays/test_period.py
@@ -22,6 +22,15 @@ def test_registered():
     assert result == expected
 
 
+def test_perioddtype_caching_dateoffset_normalize():
+    # GH 24121
+    per_d = PeriodDtype(pd.offsets.YearEnd(normalize=True))
+    assert per_d.freq.normalize
+
+    per_d2 = PeriodDtype(pd.offsets.YearEnd(normalize=False))
+    assert not per_d2.freq.normalize
+
+
 # ----------------------------------------------------------------------------
 # period_array
 


Code review comments:
diff_hunk: @@ -22,6 +22,15 @@ def test_registered():
     assert result == expected
 
 
+def test_perioddtype_caching_dateoffset_normalize():
path: pandas/tests/arrays/test_period.py
text: @user1:
Can this go with other PeriodDtype tests (as opposed to the Array)

diff_hunk: @@ -916,12 +916,12 @@ def __new__(cls, freq):
             freq = cls._parse_dtype_strict(freq)
 
         try:
-            return cls._cache_dtypes[freq.freqstr]
+            return cls._cache_dtypes[hash(freq)]
path: pandas/core/dtypes/dtypes.py
text: @user1:
would it make more sense just to pass the freq object here?