Repository: dask/dask

Issue:
Add registration accessors for 3rd-party libraries
Pandas provides an API 3rd parties to register accessors: http://pandas.pydata.org/pandas-docs/stable/development/extending.html#registering-custom-accessors.

For example, cyberpandas provides the [`.ip` series accessor](https://cyberpandas.readthedocs.io/en/latest/usage.html#ip-accessor). For cyberpandas to work well with dask.dataframe, it should also register an accessor with Dask.

We should provide

1. Similar APIs for registering accessors with dask's Series, Index, and DataFrame
2. Documentation on how to write accessors (optional dependency on dask, basics like map_partitions for elementwise operations)


Discussion:

Resolving patch:
diff --git a/dask/dataframe/_accessor.py b/dask/dataframe/_accessor.py
new file mode 100644
--- /dev/null
+++ b/dask/dataframe/_accessor.py
@@ -0,0 +1,79 @@
+import warnings
+
+
+# Ported from pandas
+# https://github.com/pandas-dev/pandas/blob/master/pandas/core/accessor.py
+
+class CachedAccessor(object):
+    """
+    Custom property-like object (descriptor) for caching accessors.
+
+    Parameters
+    ----------
+    name : str
+        The namespace this will be accessed under, e.g. ``df.foo``
+    accessor : cls
+        The class with the extension methods. The class' __init__ method
+        should expect one of a ``Series``, ``DataFrame`` or ``Index`` as
+        the single argument ``data``
+    """
+    def __init__(self, name, accessor):
+        self._name = name
+        self._accessor = accessor
+
+    def __get__(self, obj, cls):
+        if obj is None:
+            # we're accessing the attribute of the class, i.e., Dataset.geo
+            return self._accessor
+        accessor_obj = self._accessor(obj)
+        # Replace the property with the accessor object. Inspired by:
+        # http://www.pydanny.com/cached-property.html
+        # We need to use object.__setattr__ because we overwrite __setattr__ on
+        # NDFrame
+        object.__setattr__(obj, self._name, accessor_obj)
+        return accessor_obj
+
+
+def _register_accessor(name, cls):
+    def decorator(accessor):
+        if hasattr(cls, name):
+            warnings.warn(
+                'registration of accessor {!r} under name {!r} for type '
+                '{!r} is overriding a preexisting attribute with the same '
+                'name.'.format(accessor, name, cls),
+                UserWarning,
+                stacklevel=2)
+        setattr(cls, name, CachedAccessor(name, accessor))
+        cls._accessors.add(name)
+        return accessor
+    return decorator
+
+
+def register_dataframe_accessor(name):
+    """
+    Register a custom accessor on :class:`dask.dataframe.DataFrame`.
+
+    See :func:`pandas.api.extensions.register_dataframe_accessor` for more.
+    """
+    from dask.dataframe import DataFrame
+    return _register_accessor(name, DataFrame)
+
+
+def register_series_accessor(name):
+    """
+    Register a custom accessor on :class:`dask.dataframe.Series`.
+
+    See :func:`pandas.api.extensions.register_series_accessor` for more.
+    """
+    from dask.dataframe import Series
+    return _register_accessor(name, Series)
+
+
+def register_index_accessor(name):
+    """
+    Register a custom accessor on :class:`dask.dataframe.Index`.
+
+    See :func:`pandas.api.extensions.register_index_accessor` for more.
+    """
+    from dask.dataframe import Index
+    return _register_accessor(name, Index)
diff --git a/dask/dataframe/core.py b/dask/dataframe/core.py
--- a/dask/dataframe/core.py
+++ b/dask/dataframe/core.py
@@ -2003,6 +2003,7 @@ class Series(_Frame):
     _partition_type = pd.Series
     _is_partition_type = staticmethod(is_series_like)
     _token_prefix = 'series-'
+    _accessors = set()
 
     def __array_wrap__(self, array, context=None):
         if isinstance(context, tuple) and len(context) > 0:
@@ -2481,6 +2482,7 @@ class Index(Series):
     _partition_type = pd.Index
     _is_partition_type = staticmethod(is_index_like)
     _token_prefix = 'index-'
+    _accessors = set()
 
     _dt_attributes = {'nanosecond', 'microsecond', 'millisecond', 'dayofyear',
                       'minute', 'hour', 'day', 'dayofweek', 'second', 'week',
@@ -2611,6 +2613,7 @@ class DataFrame(_Frame):
     _partition_type = pd.DataFrame
     _is_partition_type = staticmethod(is_dataframe_like)
     _token_prefix = 'dataframe-'
+    _accessors = set()
 
     def __array_wrap__(self, array, context=None):
         if isinstance(context, tuple) and len(context) > 0:
diff --git a/dask/dataframe/extensions.py b/dask/dataframe/extensions.py
--- a/dask/dataframe/extensions.py
+++ b/dask/dataframe/extensions.py
@@ -4,6 +4,20 @@
 See :ref:`extensionarrays` for more.
 """
 from ..utils import Dispatch
+from ._accessor import (
+    register_dataframe_accessor,
+    register_index_accessor,
+    register_series_accessor
+)
 
 make_array_nonempty = Dispatch("make_array_nonempty")
 make_scalar = Dispatch("make_scalar")
+
+
+__all__ = [
+    'make_array_nonempty',
+    'make_scalar',
+    'register_dataframe_accessor',
+    'register_index_accessor',
+    'register_series_accessor'
+]


Test patch:
diff --git a/dask/dataframe/tests/test_accessors.py b/dask/dataframe/tests/test_accessors.py
new file mode 100644
--- /dev/null
+++ b/dask/dataframe/tests/test_accessors.py
@@ -0,0 +1,63 @@
+import contextlib
+
+import pytest
+
+
+pd = pytest.importorskip('pandas')
+import dask.dataframe as dd
+
+
+@contextlib.contextmanager
+def ensure_removed(obj, attr):
+    """Ensure that an attribute added to 'obj' during the test is
+    removed when we're done"""
+    try:
+        yield
+    finally:
+        try:
+            delattr(obj, attr)
+        except AttributeError:
+            pass
+        obj._accessors.discard(attr)
+
+
+class MyAccessor:
+
+    def __init__(self, obj):
+        self.obj = obj
+        self.item = 'item'
+
+    @property
+    def prop(self):
+        return self.item
+
+    def method(self):
+        return self.item
+
+
+@pytest.mark.parametrize('obj, registrar', [
+    (dd.Series, dd.extensions.register_series_accessor),
+    (dd.DataFrame, dd.extensions.register_dataframe_accessor),
+    (dd.Index, dd.extensions.register_index_accessor)
+])
+def test_register(obj, registrar):
+    with ensure_removed(obj, 'mine'):
+        before = set(dir(obj))
+        registrar('mine')(MyAccessor)
+        instance = dd.from_pandas(obj._partition_type([]), 2)
+        assert instance.mine.prop == 'item'
+        after = set(dir(obj))
+        assert (before ^ after) == {'mine'}
+        assert 'mine' in obj._accessors
+
+
+def test_accessor_works():
+    with ensure_removed(dd.Series, 'mine'):
+        dd.extensions.register_series_accessor('mine')(MyAccessor)
+
+        a = pd.Series([1, 2])
+        b = dd.from_pandas(a, 2)
+        assert b.mine.obj is b
+
+        assert b.mine.prop == 'item'
+        assert b.mine.method() == 'item'