Repository: geopandas/geopandas

Issue:
Consistency of predicate argument
I have realised that with changes to sindex in 0.8.0 we have introduced inconsistency in terminology. While in `sjoin`, we have an attribute `op`, in `sindex.query` we use `predicate`. Both mean the same thing and are used for the same purpose. 

Not sure what the best option would be, but I quite like literal `predicate` as I always found `op` a bit cryptic (btw, what the `o` stands for? overlay? as overlay predicate?). One way could be to have both as synonyms or to have only `predicate` but silently accept `op` as well (until some deprecation or just keep that). But even I was confused by the current situation while trying to use `op` in query.

cc @adriangb 


Discussion:
I noticed this a while ago, however I don't think there's much we can do without a hard break in API (which is why I never brought it up). PyGEOS uses the term `predicate` and `sjoin` historically uses `op` (which I guess stands for operation). I also like predicate more, especially since that's what Shapely uses ([link](https://shapely.readthedocs.io/en/latest/manual.html#binary-predicates)).

I personally feel that we should either:
(1) Continue to carry on both terminologies. This is the status quo.
(2) Make a hard API break (maybe with a deprecation warning).

I'm okay either, happy to implement the API change if needed.
I like `predicate` here because it is precise and clear.

I think it would be reasonable to deprecate `op` with a deprecation warning and have these as temporary synonyms in both functions until that point.

Resolving patch:
diff --git a/geopandas/tools/sjoin.py b/geopandas/tools/sjoin.py
--- a/geopandas/tools/sjoin.py
+++ b/geopandas/tools/sjoin.py
@@ -7,7 +7,13 @@
 
 
 def sjoin(
-    left_df, right_df, how="inner", op="intersects", lsuffix="left", rsuffix="right"
+    left_df,
+    right_df,
+    how="inner",
+    predicate="intersects",
+    lsuffix="left",
+    rsuffix="right",
+    **kwargs,
 ):
     """Spatial join of two GeoDataFrames.
 
@@ -24,11 +30,12 @@ def sjoin(
         * 'right': use keys from right_df; retain only right_df geometry column
         * 'inner': use intersection of keys from both dfs; retain only
           left_df geometry column
-    op : string, default 'intersects'
+    predicate : string, default 'intersects'
         Binary predicate. Valid values are determined by the spatial index used.
         You can check the valid values in left_df or right_df as
         ``left_df.sindex.valid_query_predicates`` or
         ``right_df.sindex.valid_query_predicates``
+        Replaces deprecated ``op`` parameter.
     lsuffix : string, default 'left'
         Suffix to apply to overlapping column names (left GeoDataFrame).
     rsuffix : string, default 'right'
@@ -84,6 +91,30 @@ def sjoin(
     Every operation in GeoPandas is planar, i.e. the potential third
     dimension is not taken into account.
     """
+    if "op" in kwargs:
+        op = kwargs.pop("op")
+        deprecation_message = (
+            "The `op` parameter is deprecated and will be removed"
+            " in a future release. Please use the `predicate` parameter"
+            " instead."
+        )
+        if predicate != "intersects" and op != predicate:
+            override_message = (
+                "A non-default value for `predicate` was passed"
+                f' (got `predicate="{predicate}"`'
+                f' in combination with `op="{op}"`).'
+                " The value of `predicate` will be overriden by the value of `op`,"
+                " , which may result in unexpected behavior."
+                f"\n{deprecation_message}"
+            )
+            warnings.warn(override_message, UserWarning, stacklevel=4)
+        else:
+            warnings.warn(deprecation_message, FutureWarning, stacklevel=4)
+        predicate = op
+    if kwargs:
+        first = next(iter(kwargs.keys()))
+        raise TypeError(f"sjoin() got an unexpected keyword argument '{first}'")
+
     _basic_checks(left_df, right_df, how, lsuffix, rsuffix)
 
     box_left_gdf = left_df.total_bounds
@@ -106,7 +137,7 @@ def sjoin(
         copy_df = _frame_join(indices, left_df, right_df, how, lsuffix, rsuffix)
         return copy_df.iloc[:0]
 
-    indices = _geom_predicate_query(left_df, right_df, op)
+    indices = _geom_predicate_query(left_df, right_df, predicate)
 
     joined = _frame_join(indices, left_df, right_df, how, lsuffix, rsuffix)
 
@@ -163,14 +194,14 @@ def _basic_checks(left_df, right_df, how, lsuffix, rsuffix):
         )
 
 
-def _geom_predicate_query(left_df, right_df, op):
+def _geom_predicate_query(left_df, right_df, predicate):
     """Compute geometric comparisons and get matching indices.
 
     Parameters
     ----------
     left_df : GeoDataFrame
     right_df : GeoDataFrame
-    op : string
+    predicate : string
         Binary predicate to query.
 
     Returns
@@ -185,7 +216,10 @@ def _geom_predicate_query(left_df, right_df, op):
         warnings.filterwarnings(
             "ignore", "Generated spatial index is empty", FutureWarning
         )
-        if op == "within":
+
+        original_predicate = predicate
+
+        if predicate == "within":
             # within is implemented as the inverse of contains
             # contains is a faster predicate
             # see discussion at https://github.com/geopandas/geopandas/pull/1421
@@ -195,7 +229,6 @@ def _geom_predicate_query(left_df, right_df, op):
         else:
             # all other predicates are symmetric
             # keep them the same
-            predicate = op
             sindex = right_df.sindex
             input_geoms = left_df.geometry
 
@@ -205,7 +238,8 @@ def _geom_predicate_query(left_df, right_df, op):
     else:
         # when sindex is empty / has no valid geometries
         indices = pd.DataFrame(columns=["_key_left", "_key_right"], dtype=float)
-    if op == "within":
+
+    if original_predicate == "within":
         # within is implemented as the inverse of contains
         # flip back the results
         indices = indices.rename(


Test patch:
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -113,6 +113,40 @@ def test_crs_mismatch(self, dfs):
         with pytest.warns(UserWarning, match="CRS mismatch between the CRS"):
             sjoin(df1, df2)
 
+    @pytest.mark.parametrize("dfs", ["default-index"], indirect=True)
+    @pytest.mark.parametrize("op", ["intersects", "contains", "within"])
+    def test_deprecated_op_param(self, dfs, op):
+        _, df1, df2, _ = dfs
+        with pytest.warns(FutureWarning, match="`op` parameter is deprecated"):
+            sjoin(df1, df2, op=op)
+
+    @pytest.mark.parametrize("dfs", ["default-index"], indirect=True)
+    @pytest.mark.parametrize("op", ["intersects", "contains", "within"])
+    @pytest.mark.parametrize("predicate", ["contains", "within"])
+    def test_deprecated_op_param_nondefault_predicate(self, dfs, op, predicate):
+        _, df1, df2, _ = dfs
+        match = "use the `predicate` parameter instead"
+        if op != predicate:
+            warntype = UserWarning
+            match = (
+                "`predicate` will be overriden by the value of `op`"
+                + r"(.|\s)*"
+                + match
+            )
+        else:
+            warntype = FutureWarning
+        with pytest.warns(warntype, match=match):
+            sjoin(df1, df2, predicate=predicate, op=op)
+
+    @pytest.mark.parametrize("dfs", ["default-index"], indirect=True)
+    def test_unknown_kwargs(self, dfs):
+        _, df1, df2, _ = dfs
+        with pytest.raises(
+            TypeError,
+            match=r"sjoin\(\) got an unexpected keyword argument 'extra_param'",
+        ):
+            sjoin(df1, df2, extra_param="test")
+
     @pytest.mark.parametrize(
         "dfs",
         [
@@ -124,13 +158,14 @@ def test_crs_mismatch(self, dfs):
         ],
         indirect=True,
     )
-    @pytest.mark.parametrize("op", ["intersects", "contains", "within"])
-    def test_inner(self, op, dfs):
+    @pytest.mark.parametrize("predicate", ["intersects", "contains", "within"])
+    @pytest.mark.parametrize("predicate_kw", ["predicate", "op"])
+    def test_inner(self, predicate, predicate_kw, dfs):
         index, df1, df2, expected = dfs
 
-        res = sjoin(df1, df2, how="inner", op=op)
+        res = sjoin(df1, df2, how="inner", **{predicate_kw: predicate})
 
-        exp = expected[op].dropna().copy()
+        exp = expected[predicate].dropna().copy()
         exp = exp.drop("geometry_y", axis=1).rename(columns={"geometry_x": "geometry"})
         exp[["df1", "df2"]] = exp[["df1", "df2"]].astype("int64")
         if index == "default-index":
@@ -167,20 +202,20 @@ def test_inner(self, op, dfs):
         ],
         indirect=True,
     )
-    @pytest.mark.parametrize("op", ["intersects", "contains", "within"])
-    def test_left(self, op, dfs):
+    @pytest.mark.parametrize("predicate", ["intersects", "contains", "within"])
+    def test_left(self, predicate, dfs):
         index, df1, df2, expected = dfs
 
-        res = sjoin(df1, df2, how="left", op=op)
+        res = sjoin(df1, df2, how="left", predicate=predicate)
 
         if index in ["default-index", "string-index"]:
-            exp = expected[op].dropna(subset=["index_left"]).copy()
+            exp = expected[predicate].dropna(subset=["index_left"]).copy()
         elif index == "named-index":
-            exp = expected[op].dropna(subset=["df1_ix"]).copy()
+            exp = expected[predicate].dropna(subset=["df1_ix"]).copy()
         elif index == "multi-index":
-            exp = expected[op].dropna(subset=["level_0_x"]).copy()
+            exp = expected[predicate].dropna(subset=["level_0_x"]).copy()
         elif index == "named-multi-index":
-            exp = expected[op].dropna(subset=["df1_ix1"]).copy()
+            exp = expected[predicate].dropna(subset=["df1_ix1"]).copy()
         exp = exp.drop("geometry_y", axis=1).rename(columns={"geometry_x": "geometry"})
         exp["df1"] = exp["df1"].astype("int64")
         if index == "default-index":
@@ -218,14 +253,14 @@ def test_empty_join(self):
             }
         )
         not_in = geopandas.GeoDataFrame({"col1": [1], "geometry": [Point(-0.5, 0.5)]})
-        empty = sjoin(not_in, polygons, how="left", op="intersects")
+        empty = sjoin(not_in, polygons, how="left", predicate="intersects")
         assert empty.index_right.isnull().all()
-        empty = sjoin(not_in, polygons, how="right", op="intersects")
+        empty = sjoin(not_in, polygons, how="right", predicate="intersects")
         assert empty.index_left.isnull().all()
-        empty = sjoin(not_in, polygons, how="inner", op="intersects")
+        empty = sjoin(not_in, polygons, how="inner", predicate="intersects")
         assert empty.empty
 
-    @pytest.mark.parametrize("op", ["intersects", "contains", "within"])
+    @pytest.mark.parametrize("predicate", ["intersects", "contains", "within"])
     @pytest.mark.parametrize(
         "empty",
         [
@@ -233,7 +268,7 @@ def test_empty_join(self):
             GeoDataFrame(geometry=GeoSeries()),
         ],
     )
-    def test_join_with_empty(self, op, empty):
+    def test_join_with_empty(self, predicate, empty):
         # Check joins with empty geometry columns/dataframes.
         polygons = geopandas.GeoDataFrame(
             {
@@ -244,11 +279,11 @@ def test_join_with_empty(self, op, empty):
                 ],
             }
         )
-        result = sjoin(empty, polygons, how="left", op=op)
+        result = sjoin(empty, polygons, how="left", predicate=predicate)
         assert result.index_right.isnull().all()
-        result = sjoin(empty, polygons, how="right", op=op)
+        result = sjoin(empty, polygons, how="right", predicate=predicate)
         assert result.index_left.isnull().all()
-        result = sjoin(empty, polygons, how="inner", op=op)
+        result = sjoin(empty, polygons, how="inner", predicate=predicate)
         assert result.empty
 
     @pytest.mark.parametrize("dfs", ["default-index", "string-index"], indirect=True)
@@ -272,20 +307,20 @@ def test_sjoin_invalid_args(self, dfs):
         ],
         indirect=True,
     )
-    @pytest.mark.parametrize("op", ["intersects", "contains", "within"])
-    def test_right(self, op, dfs):
+    @pytest.mark.parametrize("predicate", ["intersects", "contains", "within"])
+    def test_right(self, predicate, dfs):
         index, df1, df2, expected = dfs
 
-        res = sjoin(df1, df2, how="right", op=op)
+        res = sjoin(df1, df2, how="right", predicate=predicate)
 
         if index in ["default-index", "string-index"]:
-            exp = expected[op].dropna(subset=["index_right"]).copy()
+            exp = expected[predicate].dropna(subset=["index_right"]).copy()
         elif index == "named-index":
-            exp = expected[op].dropna(subset=["df2_ix"]).copy()
+            exp = expected[predicate].dropna(subset=["df2_ix"]).copy()
         elif index == "multi-index":
-            exp = expected[op].dropna(subset=["level_0_y"]).copy()
+            exp = expected[predicate].dropna(subset=["level_0_y"]).copy()
         elif index == "named-multi-index":
-            exp = expected[op].dropna(subset=["df2_ix1"]).copy()
+            exp = expected[predicate].dropna(subset=["df2_ix1"]).copy()
         exp = exp.drop("geometry_x", axis=1).rename(columns={"geometry_y": "geometry"})
         exp["df2"] = exp["df2"].astype("int64")
         if index == "default-index":
@@ -310,7 +345,7 @@ def test_right(self, op, dfs):
             exp.index.names = df2.index.names
 
         # GH 1364 fix of behaviour was done in pandas 1.1.0
-        if op == "within" and str(pd.__version__) >= LooseVersion("1.1.0"):
+        if predicate == "within" and str(pd.__version__) >= LooseVersion("1.1.0"):
             exp = exp.sort_index()
 
         assert_frame_equal(res, exp, check_index_type=False)
@@ -367,21 +402,21 @@ def test_sjoin_inner(self):
         df = sjoin(self.pointdf, self.polydf, how="inner")
         assert df.shape == (11, 8)
 
-    def test_sjoin_op(self):
+    def test_sjoin_predicate(self):
         # points within polygons
-        df = sjoin(self.pointdf, self.polydf, how="left", op="within")
+        df = sjoin(self.pointdf, self.polydf, how="left", predicate="within")
         assert df.shape == (21, 8)
         assert df.loc[1]["BoroName"] == "Staten Island"
 
         # points contain polygons? never happens so we should have nulls
-        df = sjoin(self.pointdf, self.polydf, how="left", op="contains")
+        df = sjoin(self.pointdf, self.polydf, how="left", predicate="contains")
         assert df.shape == (21, 8)
         assert np.isnan(df.loc[1]["Shape_Area"])
 
-    def test_sjoin_bad_op(self):
+    def test_sjoin_bad_predicate(self):
         # AttributeError: 'Point' object has no attribute 'spandex'
         with pytest.raises(ValueError):
-            sjoin(self.pointdf, self.polydf, how="left", op="spandex")
+            sjoin(self.pointdf, self.polydf, how="left", predicate="spandex")
 
     def test_sjoin_duplicate_column_name(self):
         pointdf2 = self.pointdf.rename(columns={"pointattr1": "Shape_Area"})
@@ -480,14 +515,14 @@ def test_sjoin_empty_geometries(self):
         df2 = sjoin(self.pointdf, self.polydf.append(empty), how="left")
         assert df2.shape == (21, 8)
 
-    @pytest.mark.parametrize("op", ["intersects", "within", "contains"])
-    def test_sjoin_no_valid_geoms(self, op):
+    @pytest.mark.parametrize("predicate", ["intersects", "within", "contains"])
+    def test_sjoin_no_valid_geoms(self, predicate):
         """Tests a completely empty GeoDataFrame."""
         empty = GeoDataFrame(geometry=[], crs=self.pointdf.crs)
-        assert sjoin(self.pointdf, empty, how="inner", op=op).empty
-        assert sjoin(self.pointdf, empty, how="right", op=op).empty
-        assert sjoin(empty, self.pointdf, how="inner", op=op).empty
-        assert sjoin(empty, self.pointdf, how="left", op=op).empty
+        assert sjoin(self.pointdf, empty, how="inner", predicate=predicate).empty
+        assert sjoin(self.pointdf, empty, how="right", predicate=predicate).empty
+        assert sjoin(empty, self.pointdf, how="inner", predicate=predicate).empty
+        assert sjoin(empty, self.pointdf, how="left", predicate=predicate).empty
 
     def test_empty_sjoin_return_duplicated_columns(self):
 
@@ -513,6 +548,6 @@ def test_sjoin_inner(self):
         countries = self.world[["geometry", "name"]]
         countries = countries.rename(columns={"name": "country"})
         cities_with_country = sjoin(
-            self.cities, countries, how="inner", op="intersects"
+            self.cities, countries, how="inner", predicate="intersects"
         )
         assert cities_with_country.shape == (172, 4)