Repository: dask/dask

Issue:
Maximum recursion depth exceeded when accessing wrong accesssors
A simple reproducable example:

```
In [1]: import pandas as pd

In [2]: import dask.dataframe as dd

In [3]: df = dd.from_pandas(pd.DataFrame({"a": [1, 2, 3]}), npartitions=2)

In [4]: hasattr(df["a"], "dt") 
Out[4]: True    # Should probably return False in this case?

In [5]: df["a"].dt
Out[5]: Fatal Python error: Cannot recover from stack overflow. 
```
The final error is a `Maximum recursion depth exceeded` in a normal Python process.

This error occurs in debuggers (`pudb` and VSCode's debugger in my case) when inspecting a column on a DataFrame.


Discussion:
Thanks for the excellent issue, I've reproduced the problem, fix up shortly.

Resolving patch:
diff --git a/dask/dataframe/accessor.py b/dask/dataframe/accessor.py
--- a/dask/dataframe/accessor.py
+++ b/dask/dataframe/accessor.py
@@ -5,7 +5,7 @@
 from toolz import partial
 
 from ..utils import derived_from
-from .utils import is_categorical_dtype, PANDAS_VERSION
+from .utils import PANDAS_VERSION
 
 
 def maybe_wrap_pandas(obj, x):
@@ -22,10 +22,7 @@ class Accessor(object):
 
     Notes
     -----
-    Subclasses should define the following attributes:
-
-    * _accessor
-    * _accessor_name
+    Subclasses should define ``_accessor_name``
     """
 
     _not_implemented = set()
@@ -35,18 +32,14 @@ def __init__(self, series):
 
         if not isinstance(series, Series):
             raise ValueError("Accessor cannot be initialized")
-        self._validate(series)
-        self._series = series
 
-    def _validate(self, series):
-        pass
+        series_meta = series._meta
+        if hasattr(series_meta, "to_series"):  # is index-like
+            series_meta = series_meta.to_series()
+        meta = getattr(series_meta, self._accessor_name)
 
-    @property
-    def _accessor(self):
-        meta = self._series._meta
-        if hasattr(meta, "to_series"):  # is index-like
-            meta = meta.to_series()
-        return getattr(meta, self._accessor_name)
+        self._meta = meta
+        self._series = series
 
     @staticmethod
     def _delegate_property(obj, accessor, attr):
@@ -85,7 +78,7 @@ def _function_map(self, attr, *args, **kwargs):
 
     @property
     def _delegates(self):
-        return set(dir(self._accessor)).difference(self._not_implemented)
+        return set(dir(self._meta)).difference(self._not_implemented)
 
     def __dir__(self):
         o = self._delegates
@@ -95,7 +88,7 @@ def __dir__(self):
 
     def __getattr__(self, key):
         if key in self._delegates:
-            if callable(getattr(self._accessor, key)):
+            if callable(getattr(self._meta, key)):
                 return partial(self._function_map, key)
             else:
                 return self._property_map(key)
@@ -127,15 +120,6 @@ class StringAccessor(Accessor):
     _accessor_name = "str"
     _not_implemented = {"get_dummies"}
 
-    def _validate(self, series):
-        if not (
-            series.dtype == "object"
-            or (
-                is_categorical_dtype(series) and series.cat.categories.dtype == "object"
-            )
-        ):
-            raise AttributeError("Can only use .str accessor with object dtype")
-
     @derived_from(pd.core.strings.StringMethods)
     def split(self, pat=None, n=-1, expand=False):
         if expand:
diff --git a/dask/dataframe/categorical.py b/dask/dataframe/categorical.py
--- a/dask/dataframe/categorical.py
+++ b/dask/dataframe/categorical.py
@@ -167,15 +167,8 @@ class CategoricalAccessor(Accessor):
     So `df.a.cat.codes` <=> `df.a.map_partitions(lambda x: x.cat.codes)`
     """
 
-    _accessor = pd.Series.cat
     _accessor_name = "cat"
 
-    def _validate(self, series):
-        if not is_categorical_dtype(series.dtype):
-            raise AttributeError(
-                "Can only use .cat accessor with a " "'category' dtype"
-            )
-
     @property
     def known(self):
         """Whether the categories are fully known"""


Test patch:
diff --git a/dask/dataframe/tests/test_accessors.py b/dask/dataframe/tests/test_accessors.py
--- a/dask/dataframe/tests/test_accessors.py
+++ b/dask/dataframe/tests/test_accessors.py
@@ -5,6 +5,7 @@
 
 pd = pytest.importorskip("pandas")
 import dask.dataframe as dd
+from dask.dataframe.utils import assert_eq
 
 
 @contextlib.contextmanager
@@ -63,3 +64,181 @@ def test_accessor_works():
 
         assert b.mine.prop == "item"
         assert b.mine.method() == "item"
+
+
+@pytest.fixture
+def df_ddf():
+    import numpy as np
+
+    df = pd.DataFrame(
+        {
+            "str_col": ["abc", "bcd", "cdef", "DEFG"],
+            "int_col": [1, 2, 3, 4],
+            "dt_col": np.array(
+                [int(1e9), int(1.1e9), int(1.2e9), None], dtype="M8[ns]"
+            ),
+        },
+        index=["E", "f", "g", "h"],
+    )
+
+    ddf = dd.from_pandas(df, 2)
+
+    return df, ddf
+
+
+def test_dt_accessor(df_ddf):
+    df, ddf = df_ddf
+
+    assert "date" in dir(ddf.dt_col.dt)
+
+    # pandas loses Series.name via datetime accessor
+    # see https://github.com/pydata/pandas/issues/10712
+    assert_eq(ddf.dt_col.dt.date, df.dt_col.dt.date, check_names=False)
+
+    # to_pydatetime returns a numpy array in pandas, but a Series in dask
+    assert_eq(
+        ddf.dt_col.dt.to_pydatetime(),
+        pd.Series(df.dt_col.dt.to_pydatetime(), index=df.index, dtype=object),
+    )
+
+    assert set(ddf.dt_col.dt.date.dask) == set(ddf.dt_col.dt.date.dask)
+    assert set(ddf.dt_col.dt.to_pydatetime().dask) == set(
+        ddf.dt_col.dt.to_pydatetime().dask
+    )
+
+
+def test_dt_accessor_not_available(df_ddf):
+    df, ddf = df_ddf
+
+    # Not available on invalid dtypes
+    with pytest.raises(AttributeError) as exc:
+        ddf.str_col.dt
+    assert ".dt accessor" in str(exc.value)
+
+
+def test_str_accessor(df_ddf):
+    df, ddf = df_ddf
+
+    # implemented methods are present in tab completion
+    assert "upper" in dir(ddf.str_col.str)
+    assert "upper" in dir(ddf.index.str)
+
+    # not implemented methods don't show up
+    assert "get_dummies" not in dir(ddf.str_col.str)
+    assert not hasattr(ddf.str_col.str, "get_dummies")
+
+    # Test simple method on both series and index
+    assert_eq(ddf.str_col.str.upper(), df.str_col.str.upper())
+    assert set(ddf.str_col.str.upper().dask) == set(ddf.str_col.str.upper().dask)
+
+    assert_eq(ddf.index.str.upper(), df.index.str.upper())
+    assert set(ddf.index.str.upper().dask) == set(ddf.index.str.upper().dask)
+
+    # make sure to pass thru args & kwargs
+    assert_eq(ddf.str_col.str.contains("a"), df.str_col.str.contains("a"))
+    assert set(ddf.str_col.str.contains("a").dask) == set(
+        ddf.str_col.str.contains("a").dask
+    )
+
+    assert_eq(
+        ddf.str_col.str.contains("d", case=False),
+        df.str_col.str.contains("d", case=False),
+    )
+    assert set(ddf.str_col.str.contains("d", case=False).dask) == set(
+        ddf.str_col.str.contains("d", case=False).dask
+    )
+
+    for na in [True, False]:
+        assert_eq(
+            ddf.str_col.str.contains("a", na=na), df.str_col.str.contains("a", na=na)
+        )
+        assert set(ddf.str_col.str.contains("a", na=na).dask) == set(
+            ddf.str_col.str.contains("a", na=na).dask
+        )
+
+    for regex in [True, False]:
+        assert_eq(
+            ddf.str_col.str.contains("a", regex=regex),
+            df.str_col.str.contains("a", regex=regex),
+        )
+        assert set(ddf.str_col.str.contains("a", regex=regex).dask) == set(
+            ddf.str_col.str.contains("a", regex=regex).dask
+        )
+
+
+def test_str_accessor_not_available(df_ddf):
+    df, ddf = df_ddf
+
+    # Not available on invalid dtypes
+    with pytest.raises(AttributeError) as exc:
+        ddf.int_col.str
+    assert ".str accessor" in str(exc.value)
+
+    assert "str" not in dir(ddf.int_col)
+
+
+def test_str_accessor_getitem(df_ddf):
+    df, ddf = df_ddf
+    assert_eq(ddf.str_col.str[:2], df.str_col.str[:2])
+    assert_eq(ddf.str_col.str[1], df.str_col.str[1])
+
+
+def test_str_accessor_extractall(df_ddf):
+    df, ddf = df_ddf
+    assert_eq(
+        ddf.str_col.str.extractall("(.*)b(.*)"), df.str_col.str.extractall("(.*)b(.*)")
+    )
+
+
+def test_str_accessor_cat(df_ddf):
+    df, ddf = df_ddf
+    sol = df.str_col.str.cat(df.str_col.str.upper(), sep=":")
+    assert_eq(ddf.str_col.str.cat(ddf.str_col.str.upper(), sep=":"), sol)
+    assert_eq(ddf.str_col.str.cat(df.str_col.str.upper(), sep=":"), sol)
+    assert_eq(
+        ddf.str_col.str.cat([ddf.str_col.str.upper(), df.str_col.str.lower()], sep=":"),
+        df.str_col.str.cat([df.str_col.str.upper(), df.str_col.str.lower()], sep=":"),
+    )
+
+    for o in ["foo", ["foo"]]:
+        with pytest.raises(TypeError):
+            ddf.str_col.str.cat(o)
+
+    with pytest.raises(NotImplementedError):
+        ddf.str_col.str.cat(sep=":")
+
+
+def test_str_accessor_noexpand():
+    s = pd.Series(["a b c d", "aa bb cc dd", "aaa bbb ccc dddd"], name="foo")
+    ds = dd.from_pandas(s, npartitions=2)
+
+    for n in [1, 2, 3]:
+        assert_eq(s.str.split(n=n, expand=False), ds.str.split(n=n, expand=False))
+
+    assert ds.str.split(n=1, expand=False).name == "foo"
+
+
+def test_str_accessor_expand():
+    s = pd.Series(["a b c d", "aa bb cc dd", "aaa bbb ccc dddd"])
+    ds = dd.from_pandas(s, npartitions=2)
+
+    for n in [1, 2, 3]:
+        assert_eq(s.str.split(n=n, expand=True), ds.str.split(n=n, expand=True))
+
+    with pytest.raises(NotImplementedError) as info:
+        ds.str.split(expand=True)
+
+    assert "n=" in str(info.value)
+
+
+@pytest.mark.xfail(reason="Need to pad columns")
+def test_str_accessor_expand_more_columns():
+    s = pd.Series(["a b c d", "aa", "aaa bbb ccc dddd"])
+    ds = dd.from_pandas(s, npartitions=2)
+
+    assert_eq(s.str.split(n=3, expand=True), ds.str.split(n=3, expand=True))
+
+    s = pd.Series(["a b c", "aa bb cc", "aaa bbb ccc"])
+    ds = dd.from_pandas(s, npartitions=2)
+
+    s.str.split(n=10, expand=True).compute()
diff --git a/dask/dataframe/tests/test_dataframe.py b/dask/dataframe/tests/test_dataframe.py
--- a/dask/dataframe/tests/test_dataframe.py
+++ b/dask/dataframe/tests/test_dataframe.py
@@ -2031,99 +2031,6 @@ def test_sample_raises():
         a.sample(frac=None)
 
 
-def test_datetime_accessor():
-    df = pd.DataFrame({"x": [1, 2, 3, 4]})
-    df["x"] = df.x.astype("M8[us]")
-
-    a = dd.from_pandas(df, 2)
-
-    assert "date" in dir(a.x.dt)
-
-    # pandas loses Series.name via datetime accessor
-    # see https://github.com/pydata/pandas/issues/10712
-    assert_eq(a.x.dt.date, df.x.dt.date, check_names=False)
-
-    # to_pydatetime returns a numpy array in pandas, but a Series in dask
-    assert_eq(
-        a.x.dt.to_pydatetime(),
-        pd.Series(df.x.dt.to_pydatetime(), index=df.index, dtype=object),
-    )
-
-    assert set(a.x.dt.date.dask) == set(a.x.dt.date.dask)
-    assert set(a.x.dt.to_pydatetime().dask) == set(a.x.dt.to_pydatetime().dask)
-
-
-def test_str_accessor():
-    df = pd.DataFrame(
-        {"x": ["abc", "bcd", "cdef", "DEFG"], "y": [1, 2, 3, 4]},
-        index=["E", "f", "g", "h"],
-    )
-
-    ddf = dd.from_pandas(df, 2)
-
-    # Check that str not in dir/hasattr for non-object columns
-    assert "str" not in dir(ddf.y)
-    assert not hasattr(ddf.y, "str")
-
-    # not implemented methods don't show up
-    assert "get_dummies" not in dir(ddf.x.str)
-    assert not hasattr(ddf.x.str, "get_dummies")
-
-    assert "upper" in dir(ddf.x.str)
-    assert_eq(ddf.x.str.upper(), df.x.str.upper())
-    assert set(ddf.x.str.upper().dask) == set(ddf.x.str.upper().dask)
-
-    assert "upper" in dir(ddf.index.str)
-    assert_eq(ddf.index.str.upper(), df.index.str.upper())
-    assert set(ddf.index.str.upper().dask) == set(ddf.index.str.upper().dask)
-
-    # make sure to pass thru args & kwargs
-    assert "contains" in dir(ddf.x.str)
-    assert_eq(ddf.x.str.contains("a"), df.x.str.contains("a"))
-    assert set(ddf.x.str.contains("a").dask) == set(ddf.x.str.contains("a").dask)
-
-    assert_eq(ddf.x.str.contains("d", case=False), df.x.str.contains("d", case=False))
-    assert set(ddf.x.str.contains("d", case=False).dask) == set(
-        ddf.x.str.contains("d", case=False).dask
-    )
-
-    for na in [True, False]:
-        assert_eq(ddf.x.str.contains("a", na=na), df.x.str.contains("a", na=na))
-        assert set(ddf.x.str.contains("a", na=na).dask) == set(
-            ddf.x.str.contains("a", na=na).dask
-        )
-
-    for regex in [True, False]:
-        assert_eq(
-            ddf.x.str.contains("a", regex=regex), df.x.str.contains("a", regex=regex)
-        )
-        assert set(ddf.x.str.contains("a", regex=regex).dask) == set(
-            ddf.x.str.contains("a", regex=regex).dask
-        )
-
-    assert_eq(ddf.x.str[:2], df.x.str[:2])
-    assert_eq(ddf.x.str[1], df.x.str[1])
-
-    # str.extractall
-    assert_eq(ddf.x.str.extractall("(.*)b(.*)"), df.x.str.extractall("(.*)b(.*)"))
-
-    # str.cat
-    sol = df.x.str.cat(df.x.str.upper(), sep=":")
-    assert_eq(ddf.x.str.cat(ddf.x.str.upper(), sep=":"), sol)
-    assert_eq(ddf.x.str.cat(df.x.str.upper(), sep=":"), sol)
-    assert_eq(
-        ddf.x.str.cat([ddf.x.str.upper(), df.x.str.lower()], sep=":"),
-        df.x.str.cat([df.x.str.upper(), df.x.str.lower()], sep=":"),
-    )
-
-    for o in ["foo", ["foo"]]:
-        with pytest.raises(TypeError):
-            ddf.x.str.cat(o)
-
-    with pytest.raises(NotImplementedError):
-        ddf.x.str.cat(sep=":")
-
-
 def test_empty_max():
     meta = make_meta({"x": "i8"})
     a = dd.DataFrame(
@@ -4096,42 +4003,6 @@ def test_map_partitions_delays_lists():
     assert any(str(L) == str(v) for v in out.__dask_graph__().values())
 
 
-def test_str_noexpand():
-    s = pd.Series(["a b c d", "aa bb cc dd", "aaa bbb ccc dddd"], name="foo")
-    ds = dd.from_pandas(s, npartitions=2)
-
-    for n in [1, 2, 3]:
-        assert_eq(s.str.split(n=n, expand=False), ds.str.split(n=n, expand=False))
-
-    assert ds.str.split(n=1, expand=False).name == "foo"
-
-
-def test_str_expand():
-    s = pd.Series(["a b c d", "aa bb cc dd", "aaa bbb ccc dddd"])
-    ds = dd.from_pandas(s, npartitions=2)
-
-    for n in [1, 2, 3]:
-        assert_eq(s.str.split(n=n, expand=True), ds.str.split(n=n, expand=True))
-
-    with pytest.raises(NotImplementedError) as info:
-        ds.str.split(expand=True)
-
-    assert "n=" in str(info.value)
-
-
-@pytest.mark.xfail(reason="Need to pad columns")
-def test_str_expand_more_columns():
-    s = pd.Series(["a b c d", "aa", "aaa bbb ccc dddd"])
-    ds = dd.from_pandas(s, npartitions=2)
-
-    assert_eq(s.str.split(n=3, expand=True), ds.str.split(n=3, expand=True))
-
-    s = pd.Series(["a b c", "aa bb cc", "aaa bbb ccc"])
-    ds = dd.from_pandas(s, npartitions=2)
-
-    s.str.split(n=10, expand=True).compute()
-
-
 def test_dtype_cast():
     df = pd.DataFrame(
         {