Repository: pandas-dev/pandas
Pull request: API: EA._can_hold_na -> EDtype.can_hold_na
- [x] closes #40574
- [ ] tests added / passed
- [x] Ensure all linting tests pass, see [here](https://pandas.pydata.org/pandas-docs/dev/development/contributing.html#code-standards) for how to run them
- [x] whatsnew entry


Problem statement:
API: EA._can_hold_na should be a dtype attribute, not EA
In practice in all the cases I'm aware of, _can_hold_na is always fixed at the EA subclass level, so we can immediately infer _can_hold_na from the dtype.  But in principle ATM an EA subclass could do

```
class WeirdEA(ExtensionArray):
      @property
      def _can_hold_na(self) -> bool:
           return some_func(self[0])
```

We should make _can_hold_na an attribute of `ExtensionDtype` to pre-empt this.

Hints from issue discussion:

Commit message: mypy fixup

Patch to review:
diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst
index 258e391b9220c..0d39b3b3ffac6 100644
--- a/doc/source/whatsnew/v1.3.0.rst
+++ b/doc/source/whatsnew/v1.3.0.rst
@@ -642,6 +642,7 @@ Other API changes
 - Partially initialized :class:`CategoricalDtype` (i.e. those with ``categories=None`` objects will no longer compare as equal to fully initialized dtype objects.
 - Accessing ``_constructor_expanddim`` on a :class:`DataFrame` and ``_constructor_sliced`` on a :class:`Series` now raise an ``AttributeError``. Previously a ``NotImplementedError`` was raised (:issue:`38782`)
 - Added new ``engine`` and ``**engine_kwargs`` parameters to :meth:`DataFrame.to_sql` to support other future "SQL engines". Currently we still only use ``SQLAlchemy`` under the hood, but more engines are planned to be supported such as ``turbodbc`` (:issue:`36893`)
+- :attr:`ExtensionArray._can_hold_na` should now be implemented on the :class:`ExtensionDtype` subclass as ``can_hold_na`` (:issue:`40574`)
 
 Build
 =====
diff --git a/pandas/core/arrays/base.py b/pandas/core/arrays/base.py
index 7dddb9f3d6f25..97c01b1f44589 100644
--- a/pandas/core/arrays/base.py
+++ b/pandas/core/arrays/base.py
@@ -35,6 +35,7 @@
 from pandas.util._decorators import (
     Appender,
     Substitution,
+    cache_readonly,
 )
 from pandas.util._validators import (
     validate_bool_kwarg,
@@ -1273,7 +1274,9 @@ def _concat_same_type(
     # such as take(), reindex(), shift(), etc.  In addition, those results
     # will then be of the ExtensionArray subclass rather than an array
     # of objects
-    _can_hold_na = True
+    @cache_readonly
+    def _can_hold_na(self) -> bool:
+        return self.dtype.can_hold_na
 
     def _reduce(self, name: str, *, skipna: bool = True, **kwargs):
         """
diff --git a/pandas/core/arrays/categorical.py b/pandas/core/arrays/categorical.py
index cb8a08f5668ac..341a3b47c7c23 100644
--- a/pandas/core/arrays/categorical.py
+++ b/pandas/core/arrays/categorical.py
@@ -353,7 +353,6 @@ class Categorical(NDArrayBackedExtensionArray, PandasObject, ObjectStringArrayMi
     # tolist is not actually deprecated, just suppressed in the __dir__
     _hidden_attrs = PandasObject._hidden_attrs | frozenset(["tolist"])
     _typ = "categorical"
-    _can_hold_na = True
 
     _dtype: CategoricalDtype
 
diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py
index ff46715d0a527..fbdfd3397ef4b 100644
--- a/pandas/core/arrays/datetimelike.py
+++ b/pandas/core/arrays/datetimelike.py
@@ -158,6 +158,9 @@ class DatetimeLikeArrayMixin(OpsMixin, NDArrayBackedExtensionArray):
     _is_recognized_dtype: Callable[[DtypeObj], bool]
     _recognized_scalars: tuple[type, ...]
     _ndarray: np.ndarray
+    # Incompatible types in assignment (expression has type "bool", base class
+    # "ExtensionArray" defined the type as "Callable[[ExtensionArray], bool]")
+    _can_hold_na = True  # type: ignore[assignment]
 
     def __init__(self, data, dtype: Dtype | None = None, freq=None, copy=False):
         raise AbstractMethodError(self)
diff --git a/pandas/core/dtypes/base.py b/pandas/core/dtypes/base.py
index 9671c340a0a92..d8833e81a9714 100644
--- a/pandas/core/dtypes/base.py
+++ b/pandas/core/dtypes/base.py
@@ -367,6 +367,13 @@ def _get_common_dtype(self, dtypes: list[DtypeObj]) -> DtypeObj | None:
         else:
             return None
 
+    @property
+    def can_hold_na(self) -> bool:
+        """
+        Can arrays of this dtype hold NA values?
+        """
+        return True
+
 
 def register_extension_dtype(cls: type[E]) -> type[E]:
     """
diff --git a/pandas/core/internals/blocks.py b/pandas/core/internals/blocks.py
index 4f1b16e747394..cc6c08c0517cc 100644
--- a/pandas/core/internals/blocks.py
+++ b/pandas/core/internals/blocks.py
@@ -173,15 +173,15 @@ def is_view(self) -> bool:
         return values.base is not None
 
     @final
-    @property
+    @cache_readonly
     def _can_hold_na(self) -> bool:
         """
         Can we store NA values in this Block?
         """
-        values = self.values
-        if isinstance(values, np.ndarray):
-            return values.dtype.kind not in ["b", "i", "u"]
-        return values._can_hold_na
+        dtype = self.dtype
+        if isinstance(dtype, np.dtype):
+            return dtype.kind not in ["b", "i", "u"]
+        return dtype.can_hold_na
 
     @final
     @cache_readonly
diff --git a/pandas/tests/extension/test_external_block.py b/pandas/tests/extension/test_external_block.py
index 2402c70a166b7..18ea84305f150 100644
--- a/pandas/tests/extension/test_external_block.py
+++ b/pandas/tests/extension/test_external_block.py
@@ -14,9 +14,9 @@
 class CustomBlock(ExtensionBlock):
 
     _holder = np.ndarray
-    # error: Cannot override final attribute "_can_hold_na"
-    # (previously declared in base class "Block")
-    _can_hold_na = False  # type: ignore[misc]
+    # Incompatible types in assignment (expression has type "bool", base class
+    # "Block" defined the type as "Callable[[Block], bool]")
+    _can_hold_na = False  # type: ignore[assignment,misc]
 
 
 @pytest.fixture


Code review comments:
diff_hunk: @@ -158,6 +158,9 @@ class DatetimeLikeArrayMixin(OpsMixin, NDArrayBackedExtensionArray):
     _is_recognized_dtype: Callable[[DtypeObj], bool]
     _recognized_scalars: tuple[type, ...]
     _ndarray: np.ndarray
+    # Incompatible types in assignment (expression has type "bool", base class
+    # "ExtensionArray" defined the type as "Callable[[ExtensionArray], bool]")
+    _can_hold_na = True  # type: ignore[assignment]
path: pandas/core/arrays/datetimelike.py
text: @user1:
if u make this a property (like u do below) then can avoid the typing issue