Repository: geopandas/geopandas (python)

Issue:
Use geo accessor for GeoSeries methods?
The next version of pandas will introduce a [`.dt` series accessor](http://pandas-docs.github.io/pandas-docs-travis/whatsnew.html#dt-accessor) for datetime methods, along `.cat` for [categoricals](http://pandas-docs.github.io/pandas-docs-travis/whatsnew.html#categoricals-in-series-dataframe) and the existing `.str` for string methods.

From this perspective, would it make sense to have geo-specific methods use a `.geo` attribute? e.g., `s.geo.convex_hull` instead of the current `s.convex_hull`.

Just thought I'd throw that out there for consideration, especially if tighter integration with pandas itself is desired at some point (e.g., to plug into the pandas internals at a lower level such that `GeoSeries` and `GeoDataFrame` are not necessary).


Pull request:
ENH: register a wrapper to `GeoSeries` as the `pandas.Series.geo` accessor
This allows `geopandas` to be used like other extension dtypes.

```python
import pandas
import shapely
import geopandas

series = pandas.Series([shapely.Point(1, 2), shapely.Point(3, 4), shapely.Point(5, 6)], dtype=geopandas.array.GeometryDtype())

# Edit: Removed DataFrame accessor changes from this PR.

series.geo.x
# Out[5]: 
# 0    1.0
# 1    3.0
# 2    5.0
# dtype: float64
```

TODO

- [x] Tests
- [x] Docs

Fixes #166 
Related #680

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 100c14f3..b9d6dacd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,12 @@ Deprecations and compatibility notes:
 - The `GeoSeries.select` method wrapping the pandas `Series.select` method has been removed.
   The upstream method no longer exists in all supported version of pandas (#3394).
 
+New features and improvements:
+
+- Added `geopandas.accessors` module. Import this module to register a
+  `pandas.Series.geo` accessor, which exposes GeoSeries methods via pandas's
+  extension mechanism (#3272).
+
 ## Version 1.0.1 (July 2, 2024)
 
 Bug fixes:
diff --git a/doc/source/gallery/pandas_accessor.ipynb b/doc/source/gallery/pandas_accessor.ipynb
new file mode 100644
index 00000000..a125eaca
--- /dev/null
+++ b/doc/source/gallery/pandas_accessor.ipynb
@@ -0,0 +1,139 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Using GeoPandas via the pandas \"geo\" accessor\n",
+    "\n",
+    "GeoPandas can be used from pandas with the \"geo\" accessor, which is registered on `pandas.Series` when `geopandas.accessors` is first imported."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Import geopandas.accessors to register the \"geo\" accessor.\n",
+    "import geopandas.accessors  # noqa # pylint: disable=unused-import\n",
+    "import pandas as pd\n",
+    "from shapely import Point"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Using the Series.geo accessor\n",
+    "\n",
+    "Construct a Series using the GeometryDtype."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "0    POINT (1 2)\n",
+       "1    POINT (3 4)\n",
+       "2    POINT (5 6)\n",
+       "dtype: geometry"
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "s = pd.Series(\n",
+    "    [Point(1, 2), Point(3, 4), Point(5, 6)],\n",
+    "    dtype='geometry',\n",
+    ")\n",
+    "s"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "All GeoSeries methods and properties are available via the \"geo\" accessor."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "0    1.0\n",
+       "1    3.0\n",
+       "2    5.0\n",
+       "dtype: float64"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "s.geo.x"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "0     4.472136\n",
+       "1    10.000000\n",
+       "2    15.620499\n",
+       "dtype: float64"
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "s2 = pd.Series(\n",
+    "    [Point(-1, -2), Point(-3, -4), Point(-5, -6)],\n",
+    "    dtype='geometry',\n",
+    ")\n",
+    "s.geo.distance(s2)"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "dev-3.11",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/geopandas/accessors.py b/geopandas/accessors.py
new file mode 100644
index 00000000..cd8f8e0e
--- /dev/null
+++ b/geopandas/accessors.py
@@ -0,0 +1,28 @@
+"""
+Accessors for accessing GeoPandas functionality via pandas extension dtypes
+"""
+
+import pandas.api.extensions
+
+from geopandas import GeoSeries
+from geopandas.array import GeometryDtype
+
+
+@pandas.api.extensions.register_series_accessor("geo")
+class GeoSeriesAccessor:
+    """Series.geo accessor to expose GeoSeries methods on pandas Series.
+
+    Parameters
+    ----------
+    series : pandas.Series
+        A Series with geometry dtype.
+    """
+
+    def __init__(self, series):
+        if not isinstance(series.dtype, GeometryDtype):
+            raise AttributeError("Can only use .geo accessor with GeometryDtype values")
+
+        self._geoseries = GeoSeries(series)
+
+    def __getattr__(self, name):
+        return getattr(self._geoseries, name)
diff --git a/geopandas/base.py b/geopandas/base.py
index a2dfdb00..2d4efbab 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -34,6 +34,18 @@ def _delegate_binary_method(op, this, other, align, *args, **kwargs):
     else:
         maybe_warn = False
     this = this.geometry
+
+    # Use same alignment logic, regardless of if `other` is Series or GeoSeries.
+    if (
+        not isinstance(other, GeoPandasBase)
+        and isinstance(other, pd.Series)
+        and isinstance(other.dtype, GeometryDtype)
+    ):
+        # Avoid circular imports by importing here.
+        import geopandas.geoseries
+
+        other = geopandas.geoseries.GeoSeries(other)
+
     if isinstance(other, GeoPandasBase):
         if align and not this.index.equals(other.index):
             if maybe_warn:


Test patch:
diff --git a/geopandas/tests/test_pandas_accessor.py b/geopandas/tests/test_pandas_accessor.py
new file mode 100644
index 00000000..ad81b550
--- /dev/null
+++ b/geopandas/tests/test_pandas_accessor.py
@@ -0,0 +1,73 @@
+"""Ensure geopandas methods can be accessed via pandas 'geo' accessor."""
+
+import re
+
+import pandas as pd
+
+from shapely.geometry import Point
+
+# Use this import to register the "geo" accessor.
+import geopandas.accessors  # noqa: F401 # pylint: disable=unused-import
+from geopandas.array import GeometryDtype
+
+import pandas.testing
+import pytest
+
+
+@pytest.fixture
+def s():
+    return pd.Series(
+        [Point(x, y) for x, y in zip(range(3), range(3))], dtype=GeometryDtype()
+    )
+
+
+@pytest.fixture
+def s2():
+    return pd.Series(
+        [Point(x, y + 1) for x, y in zip(range(3), range(3))], dtype=GeometryDtype()
+    )
+
+
+def test_series_geo_buffer(s):
+    """Ensure returned geometry values have the expected dtype."""
+    got = s.geo.buffer(0.2)
+    assert isinstance(got.dtype, GeometryDtype)
+
+    # Double-check that the .geo accessor works on the result.
+    radius = got.geo.minimum_bounding_radius()
+    for row in radius.index:
+        assert radius[row] >= 0.1999  # Allow for some rounding error.
+
+
+def test_series_geo_distance(s, s2):
+    got = s.geo.distance(s2)
+    pandas.testing.assert_series_equal(
+        got,
+        pd.Series([1.0, 1.0, 1.0]),
+    )
+
+
+def test_series_geo_x(s):
+    x = s.geo.x
+    pandas.testing.assert_series_equal(
+        x,
+        pd.Series([0.0, 1.0, 2.0]),
+    )
+
+
+def test_series_geo_x_attributeerror_for_not_geo_dtype():
+    s = pd.Series([1, 2, 3])
+
+    with pytest.raises(
+        AttributeError,
+        match=re.escape("Can only use .geo accessor with GeometryDtype values"),
+    ):
+        s.geo.x
+
+
+def test_series_geo_y(s):
+    y = s.geo.y
+    pandas.testing.assert_series_equal(
+        y,
+        pd.Series([0.0, 1.0, 2.0]),
+    )