Repository: ibis-project/ibis

Issue:
docs: operation support matrix incorrectly states pandas/dask support geospatial operations
The [operation support matrix](https://ibis-project.org/docs/3.2.0/backends/support_matrix/) currently incorrectly states that pandas/dask support geospatial operations.

This is because:

- The `gen_matrix` [script](https://github.com/ibis-project/ibis/blob/master/gen_matrix.py) uses `backend.has_operation` to check if an operation is supported for a backend.
- The `pandas`/`dask` backends implement this method by checking if the operation type (or a parent class of that type) are registered by the `execute_node`/`pre_execute` dispatch functions.
- The current implementation of the pandas/dask backends register [a single handler](https://github.com/ibis-project/ibis/blob/1a083f6d2f5c30d644a61fee8b01399e22b9ec15/ibis/backends/pandas/execution/generic.py#L772) for base classes like `ops.Binary` rather than individual handlers for subclasses like `ops.Add`
- The geospatial operations also subclass from these base classes, and are thus mistakenly identified as supported

The "proper" fix for this is to probably rework the pandas `execute_node` dispatch registry to avoid registering handlers for base classes like `Binary`, instead registering handlers for each individual binary op.

A more pragmatic fix would be to add a hardcoded exception to either `has_operation` or the `gen_matrix` script to mark the geospatial operations as unsupported.


Discussion:

Resolving patch:
diff --git a/ibis/backends/pandas/__init__.py b/ibis/backends/pandas/__init__.py
--- a/ibis/backends/pandas/__init__.py
+++ b/ibis/backends/pandas/__init__.py
@@ -180,6 +180,11 @@ def _get_operations(cls):
 
     @classmethod
     def has_operation(cls, operation: type[ops.Value]) -> bool:
+        # Pandas doesn't support geospatial ops, but the dispatcher implements
+        # a common base class that makes it appear that it does. Explicitly
+        # exclude these operations.
+        if issubclass(operation, (ops.GeoSpatialUnOp, ops.GeoSpatialBinOp)):
+            return False
         op_classes = cls._get_operations()
         return operation in op_classes or any(
             issubclass(operation, op_impl) for op_impl in op_classes
diff --git a/ibis/backends/polars/__init__.py b/ibis/backends/polars/__init__.py
--- a/ibis/backends/polars/__init__.py
+++ b/ibis/backends/polars/__init__.py
@@ -125,6 +125,11 @@ def _get_operations(cls):
 
     @classmethod
     def has_operation(cls, operation: type[ops.Value]) -> bool:
+        # Polars doesn't support geospatial ops, but the dispatcher implements
+        # a common base class that makes it appear that it does. Explicitly
+        # exclude these operations.
+        if issubclass(operation, (ops.GeoSpatialUnOp, ops.GeoSpatialBinOp)):
+            return False
         op_classes = cls._get_operations()
         return operation in op_classes or any(
             issubclass(operation, op_impl) for op_impl in op_classes


Test patch:
diff --git a/ibis/backends/dask/tests/test_client.py b/ibis/backends/dask/tests/test_client.py
--- a/ibis/backends/dask/tests/test_client.py
+++ b/ibis/backends/dask/tests/test_client.py
@@ -1,16 +1,14 @@
 import re
 
+import dask.dataframe as dd
 import numpy as np
 import pandas as pd
 import pytest
+from dask.dataframe.utils import tm
 from pytest import param
 
 import ibis
-
-dd = pytest.importorskip("dask.dataframe")
-from dask.dataframe.utils import tm  # noqa: E402
-
-from ibis.backends.dask.client import DaskTable  # noqa: E402
+from ibis.backends.dask.client import DaskTable
 
 
 def make_dask_data_frame(npartitions):
diff --git a/ibis/backends/pandas/tests/test_client.py b/ibis/backends/pandas/tests/test_client.py
--- a/ibis/backends/pandas/tests/test_client.py
+++ b/ibis/backends/pandas/tests/test_client.py
@@ -5,7 +5,7 @@
 from pytest import param
 
 import ibis
-from ibis.backends.pandas.client import PandasTable  # noqa: E402
+from ibis.backends.pandas.client import PandasTable
 
 
 @pytest.fixture
diff --git a/ibis/backends/tests/test_client.py b/ibis/backends/tests/test_client.py
--- a/ibis/backends/tests/test_client.py
+++ b/ibis/backends/tests/test_client.py
@@ -12,6 +12,7 @@
 import ibis
 import ibis.common.exceptions as com
 import ibis.expr.datatypes as dt
+import ibis.expr.operations as ops
 import ibis.expr.types as ir
 from ibis.util import guid
 
@@ -812,3 +813,19 @@ def test_repr_mimebundle(alltypes, interactive, expr_type):
                 assert val not in reprs[format]
     finally:
         ibis.options.interactive = old
+
+
+@pytest.mark.never(
+    ["postgres", "mysql"],
+    reason="These backends explicitly do support Geo operations",
+)
+def test_has_operation_no_geo(con):
+    """Previously some backends mistakenly reported Geo operations as
+    supported.
+
+    Since most backends don't support Geo operations, we test that
+    they're excluded here, skipping the few backends that explicitly do
+    support them.
+    """
+    for op in [ops.GeoDistance, ops.GeoAsText, ops.GeoUnaryUnion]:
+        assert not con.has_operation(op)