Repository: geopandas/geopandas
Pull request #2026 (2021-07-26 16:58:10)

TYP: Add type hints to geodataframe.py
Added type hints.
See #1991

steveberdy: @martinfleis Do you have any particular feedback for this PR that I could use in the other type hint PRs?
martinfleis: @steveberdy sorry, it is on my to-do list but I haven't managed to get to it yet.
TLouf: To give you some feedback, I have two general remarks:
- Since you're importing `annotations` from `__future__`, you may use the `|` operator instead of `Union` (see [PEP 604](https://www.python.org/dev/peps/pep-0604/)). For instance, this would simplify `Optional[Union[dict, str]]` to `dict | str | None`, which is more legible.
- It's probably ok to leave as is for this PR, but annotating input `crs` as `Any` is not ideal. [As `pyproj` accepts a variety of inputs](https://pyproj4.github.io/pyproj/stable/api/crs/crs.html#pyproj.crs.CRS.from_user_input), this type would probably have its place in a `_typing.py`. But ideally this type should actually be defined in `pyproj` and imported here (I think I'll raise an issue in there).
martinfleis: @m-richards what is the state of this? Updated and ready to review?
m-richards: > @m-richards what is the state of this? Updated and ready to review?

Not yet, I started updating but would like to do another parse through first
m-richards: I've just updated this again, hopefully should be ready for review. This is mainly limited to low hanging fruit, anything that is not easy to type is left without an annotation (e.g. arguments to set geometry, which could be a Series, GeoSeries, numpy array of shapely objects a list of shapely objects etc) and anything that pandas types with a custom type alias I haven't typed - these are private in pandas so we shouldn't just import them.
steveberdy: I'm glad you're still planning on using type hints. It's been so long since I've worked on this that I don't really know the library as well as I did when creating this PR. Let me know if you need anything from me, though. I'll keep my fork as long as these PRs are open
m-richards: Time for my annual attempt to progress this. Goal again is to do the easy things and leave the things which are harder until shapely has a solution for typing. Main value I see is being able to be explicit about return types statically.

Patch:
diff --git a/.coveragerc b/.coveragerc
index 452c3859..e6212ca4 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,6 +1,7 @@
 [report]
 exclude_lines =
     pragma: no cover
+    if TYPE_CHECKING:
     def __repr__
     raise AssertionError
     raise NotImplementedError
diff --git a/.gitignore b/.gitignore
index fa41cb46..435a84f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,6 +73,8 @@ doc/source/getting_started/my_file.geojson
 
 .ruff_cache
 .env
+doc/test_fiona.gpkg
+doc/test_pyogrio.gpkg
 # pixi environments
 .pixi
 *.egg-info
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9d6dacd..4224727e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@
 
 New features and improvements:
 
+- Added options to return the result of `SpatialIndex.query` in a form of a dense or a
+  sparse boolean array. This adds optional dependency on `scipy` for the sparse output.
+  Note that this also changes the previously undocumented behaviour of the `output_format`
+  keyword (#1674).
 - Add ``grid_size`` parameter to ``union_all`` and ``dissolve`` (#3445).
 - `GeoDataFrame.plot` now supports `pd.Index` as an input for the `column` keyword (#3463).
 - Avoid change of the plot aspect when plotting missing values (#3438).
diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst
index 6246f998..30377507 100644
--- a/doc/source/getting_started/install.rst
+++ b/doc/source/getting_started/install.rst
@@ -158,6 +158,7 @@ Further, optional dependencies are:
 - `GeoAlchemy2`_ (optional; for writing to PostGIS)
 - `geopy`_ (optional; for geocoding)
 - `pointpats`_ (optional; for advanced point sampling)
+- `scipy`_ (optional; for sparse output of spatial indexing)
 
 
 For plotting, these additional packages may be used:
@@ -214,3 +215,5 @@ For plotting, these additional packages may be used:
 .. _pointpats: https://pysal.org/pointpats/
 
 .. _folium: https://python-visualization.github.io/folium/stable/
+
+.. _scipy: https://docs.scipy.org/doc/scipy/
\ No newline at end of file
diff --git a/geopandas/array.py b/geopandas/array.py
index ed917ce1..d818a2ce 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -52,7 +52,7 @@ geometry_type_values = np.array(list(type_mapping.values()), dtype=object)
 class GeometryDtype(ExtensionDtype):
     type = BaseGeometry
     name = "geometry"
-    na_value = np.nan
+    na_value = None
 
     @classmethod
     def construct_from_string(cls, string):
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index c6cc2412..f716ea8c 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1,5 +1,9 @@
+from __future__ import annotations
+
 import json
+import typing
 import warnings
+from typing import Any, Literal
 
 import numpy as np
 import pandas as pd
@@ -9,7 +13,7 @@ import shapely.errors
 from shapely.geometry import mapping, shape
 from shapely.geometry.base import BaseGeometry
 
-import geopandas.io
+import geopandas
 from geopandas.array import GeometryArray, GeometryDtype, from_shapely, to_wkb, to_wkt
 from geopandas.base import GeoPandasBase, is_geometry_type
 from geopandas.explore import _explore
@@ -24,7 +28,24 @@ else:
     from pandas.core.accessor import CachedAccessor as Accessor
 
 
-def _geodataframe_constructor_with_fallback(*args, **kwargs):
+if typing.TYPE_CHECKING:
+    import os
+    from collections.abc import Iterable
+
+    import folium
+    import sqlalchemy.text
+
+    from pyproj import CRS
+
+    from geopandas.io.arrow import (
+        PARQUET_GEOMETRY_ENCODINGS,
+        SUPPORTED_VERSIONS_LITERAL,
+    )
+
+
+def _geodataframe_constructor_with_fallback(
+    *args, **kwargs
+) -> pd.DataFrame | GeoDataFrame:
     """
     A flexible constructor for GeoDataFrame._constructor, which falls back
     to returning a DataFrame (if a certain operation does not preserve the
@@ -38,7 +59,7 @@ def _geodataframe_constructor_with_fallback(*args, **kwargs):
     return df
 
 
-def _ensure_geometry(data, crs=None):
+def _ensure_geometry(data, crs: Any | None = None) -> GeoSeries | GeometryArray:
     """
     Ensure the data is of geometry dtype or converted to it.
 
@@ -141,7 +162,14 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
 
     _geometry_column_name = None
 
-    def __init__(self, data=None, *args, geometry=None, crs=None, **kwargs):
+    def __init__(
+        self,
+        data=None,
+        *args,
+        geometry: Any | None = None,
+        crs: Any | None = None,
+        **kwargs,
+    ):
         if (
             kwargs.get("copy") is None
             and isinstance(data, DataFrame)
@@ -234,7 +262,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         else:
             super().__setattr__(attr, val)
 
-    def _get_geometry(self):
+    def _get_geometry(self) -> GeoSeries:
         if self._geometry_column_name not in self:
             if self._geometry_column_name is None:
                 msg = (
@@ -275,7 +303,31 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         fget=_get_geometry, fset=_set_geometry, doc="Geometry data for GeoDataFrame"
     )
 
-    def set_geometry(self, col, drop=None, inplace=False, crs=None):
+    @typing.overload
+    def set_geometry(
+        self,
+        col,
+        drop: bool | None = ...,
+        inplace: Literal[True] = ...,
+        crs: Any | None = ...,
+    ) -> None: ...
+
+    @typing.overload
+    def set_geometry(
+        self,
+        col,
+        drop: bool | None = ...,
+        inplace: Literal[False] = ...,
+        crs: Any | None = ...,
+    ) -> GeoDataFrame: ...
+
+    def set_geometry(
+        self,
+        col,
+        drop: bool | None = None,
+        inplace: bool = False,
+        crs: Any | None = None,
+    ) -> GeoDataFrame | None:
         """
         Set the GeoDataFrame geometry using either an existing column or
         the specified input. By default yields a new object.
@@ -427,7 +479,21 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         if not inplace:
             return frame
 
-    def rename_geometry(self, col, inplace=False):
+    @typing.overload
+    def rename_geometry(
+        self,
+        col: str,
+        inplace: Literal[True] = ...,
+    ) -> None: ...
+
+    @typing.overload
+    def rename_geometry(
+        self,
+        col: str,
+        inplace: Literal[False] = ...,
+    ) -> GeoDataFrame: ...
+
+    def rename_geometry(self, col: str, inplace: bool = False) -> GeoDataFrame | None:
         """
         Renames the GeoDataFrame geometry column to
         the specified name. By default yields a new object.
@@ -452,9 +518,6 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         >>> df.geometry.name
         'geom1'
 
-        Returns
-        -------
-        geodataframe : GeoDataFrame
 
         See also
         --------
@@ -472,17 +535,21 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
             self.set_geometry(col, inplace=inplace)
 
     @property
-    def active_geometry_name(self):
+    def active_geometry_name(self) -> Any:
         """Return the name of the active geometry column
 
-        Returns a string name if a GeoDataFrame has an active geometry column set.
-        Otherwise returns None. You can also access the active geometry column using the
+        Returns a name if a GeoDataFrame has an active geometry column set,
+        otherwise returns None. The return type is usually a string, but may be
+        an integer, tuple or other hashable, depending on the contents of the
+        dataframe columns.
+
+        You can also access the active geometry column using the
         ``.geometry`` property. You can set a GeoSeries to be an active geometry
         using the :meth:`~GeoDataFrame.set_geometry` method.
 
         Returns
         -------
-        str
+        str or other index label supported by pandas
             name of an active geometry column or None
 
         See also
@@ -492,7 +559,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         return self._geometry_column_name
 
     @property
-    def crs(self):
+    def crs(self) -> CRS:
         """
         The Coordinate Reference System (CRS) represented as a ``pyproj.CRS``
         object.
@@ -535,7 +602,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
             )
 
     @crs.setter
-    def crs(self, value):
+    def crs(self, value) -> None:
         """Sets the value of the crs"""
         if self._geometry_column_name is None:
             raise ValueError(
@@ -562,7 +629,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
                 "the active geometry column.",
             )
 
-    def __setstate__(self, state):
+    def __setstate__(self, state) -> None:
         # overriding DataFrame method for compat with older pickles (CRS handling)
         crs = None
         if isinstance(state, dict):
@@ -593,7 +660,13 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
             pass
 
     @classmethod
-    def from_dict(cls, data, geometry=None, crs=None, **kwargs):
+    def from_dict(
+        cls,
+        data: dict,
+        geometry=None,
+        crs: Any | None = None,
+        **kwargs,
+    ) -> GeoDataFrame:
         """
         Construct GeoDataFrame from dict of array-like or dicts by
         overriding DataFrame.from_dict method with geometry and crs
@@ -619,7 +692,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         return cls(dataframe, geometry=geometry, crs=crs)
 
     @classmethod
-    def from_file(cls, filename, **kwargs):
+    def from_file(cls, filename: os.PathLike | typing.IO, **kwargs) -> GeoDataFrame:
         """Alternate constructor to create a ``GeoDataFrame`` from a file.
 
         It is recommended to use :func:`geopandas.read_file` instead.
@@ -663,14 +736,16 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
 
         See also
         --------
-        read_file : read file to GeoDataFame
+        read_file : read file to GeoDataFrame
         GeoDataFrame.to_file : write GeoDataFrame to file
 
         """
         return geopandas.io.file._read_file(filename, **kwargs)
 
     @classmethod
-    def from_features(cls, features, crs=None, columns=None):
+    def from_features(
+        cls, features, crs: Any | None = None, columns: Iterable[str] | None = None
+    ) -> GeoDataFrame:
         """
         Alternate constructor to create GeoDataFrame from an iterable of
         features or a feature collection.
@@ -759,16 +834,16 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
     @classmethod
     def from_postgis(
         cls,
-        sql,
+        sql: str | sqlalchemy.text,
         con,
-        geom_col="geom",
-        crs=None,
-        index_col=None,
-        coerce_float=True,
-        parse_dates=None,
-        params=None,
-        chunksize=None,
-    ):
+        geom_col: str = "geom",
+        crs: Any | None = None,
+        index_col: str | list[str] | None = None,
+        coerce_float: bool = True,
+        parse_dates: list | dict | None = None,
+        params: list | tuple | dict | None = None,
+        chunksize: int | None = None,
+    ) -> GeoDataFrame:
         """
         Alternate constructor to create a ``GeoDataFrame`` from a sql query
         containing a geometry column in WKB representation.
@@ -841,7 +916,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         return df
 
     @classmethod
-    def from_arrow(cls, table, geometry=None):
+    def from_arrow(cls, table, geometry: str | None = None) -> GeoDataFrame:
         """
         Construct a GeoDataFrame from a Arrow table object based on GeoArrow
         extension types.
@@ -877,8 +952,13 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
         return arrow_to_geopandas(table, geometry=geometry)
 
     def to_json(
-        self, na="null", show_bbox=False, drop_id=False, to_wgs84=False, **kwargs
-    ):
+        self,
+        na: Literal["null", "drop", "keep"] = "null",
+        show_bbox: bool = False,
+        drop_id: bool = False,
+        to_wgs84: bool = False,
+        **kwargs,
+    ) -> str:
         """
         Returns a GeoJSON representation of the ``GeoDataFrame`` as a string.
 
@@ -974,7 +1054,7 @@ es": {"name": "urn:ogc:def:crs:EPSG::3857"}}}'
         return json.dumps(geo, **kwargs)
 
     @property
-    def __geo_interface__(self):
+    def __geo_interface__(self) -> dict:
         """Returns a ``GeoDataFrame`` as a python feature collection.
 
         Implements the `geo_interface`. The returned python data structure
@@ -1007,7 +1087,9 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
         """
         return self.to_geo_dict(na="null", show_bbox=True, drop_id=False)
 
-    def iterfeatures(self, na="null", show_bbox=False, drop_id=False):
+    def iterfeatures(
+        self, na: str = "null", show_bbox: bool = False, drop_id: bool = False
+    ) -> typing.Generator[dict]:
         """
         Returns an iterator that yields feature dictionaries that comply with
         __geo_interface__
@@ -1115,7 +1197,9 @@ individually so that features may have different properties
 
                 yield feature
 
-    def to_geo_dict(self, na="null", show_bbox=False, drop_id=False):
+    def to_geo_dict(
+        self, na: str | None = "null", show_bbox: bool = False, drop_id: bool = False
+    ) -> dict:
         """
         Returns a python feature collection representation of the GeoDataFrame
         as a dictionary with a list of features based on the ``__geo_interface__``
@@ -1173,7 +1257,7 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
 
         return geo
 
-    def to_wkb(self, hex=False, **kwargs):
+    def to_wkb(self, hex: bool = False, **kwargs) -> pd.DataFrame:
         """
         Encode all geometry columns in the GeoDataFrame to WKB.
 
@@ -1200,7 +1284,7 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
 
         return df
 
-    def to_wkt(self, **kwargs):
+    def to_wkt(self, **kwargs) -> pd.DataFrame:
         """
         Encode all geometry columns in the GeoDataFrame to WKT.
 
@@ -1224,7 +1308,12 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
         return df
 
     def to_arrow(
-        self, *, index=None, geometry_encoding="WKB", interleaved=True, include_z=None
+        self,
+        *,
+        index: bool | None = None,
+        geometry_encoding: PARQUET_GEOMETRY_ENCODINGS = "WKB",
+        interleaved: bool = True,
+        include_z: bool | None = None,
     ):
         """Encode a GeoDataFrame to GeoArrow format.
 
@@ -1313,14 +1402,14 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
 
     def to_parquet(
         self,
-        path,
-        index=None,
-        compression="snappy",
-        geometry_encoding="WKB",
-        write_covering_bbox=False,
-        schema_version=None,
+        path: os.PathLike | typing.IO,
+        index: bool | None = None,
+        compression: str = "snappy",
+        geometry_encoding: PARQUET_GEOMETRY_ENCODINGS = "WKB",
+        write_covering_bbox: bool = False,
+        schema_version: SUPPORTED_VERSIONS_LITERAL | None = None,
         **kwargs,
-    ):
+    ) -> None:
         """Write a GeoDataFrame to the Parquet format.
 
         By default, all geometry columns present are serialized to WKB format
@@ -1399,7 +1488,12 @@ default 'snappy'
         )
 
     def to_feather(
-        self, path, index=None, compression=None, schema_version=None, **kwargs
+        self,
+        path: os.PathLike,
+        index: bool | None = None,
+        compression: str | None = None,
+        schema_version: SUPPORTED_VERSIONS_LITERAL | None = None,
+        **kwargs,
     ):
         """Write a GeoDataFrame to the Feather format.
 
@@ -1425,7 +1519,7 @@ default 'snappy'
             GeoParquet specification version; if not provided will default to
             latest supported version.
         kwargs
-            Additional keyword arguments passed to to
+            Additional keyword arguments passed to
             :func:`pyarrow.feather.write_feather`.
 
         Examples
@@ -1450,7 +1544,14 @@ default 'snappy'
             **kwargs,
         )
 
-    def to_file(self, filename, driver=None, schema=None, index=None, **kwargs):
+    def to_file(
+        self,
+        filename: os.PathLike | typing.IO,
+        driver: str | None = None,
+        schema: dict | None = None,
+        index: bool | None = None,
+        **kwargs,
+    ):
         """Write the ``GeoDataFrame`` to a file.
 
         By default, an ESRI shapefile is written, but any OGR data source
@@ -1548,7 +1649,31 @@ default 'snappy'
 
         _to_file(self, filename, driver, schema, index, **kwargs)
 
-    def set_crs(self, crs=None, epsg=None, inplace=False, allow_override=False):
+    @typing.overload
+    def set_crs(
+        self,
+        crs: Any | None = ...,
+        epsg: int | None = ...,
+        inplace: Literal[True] = ...,
+        allow_override: bool = ...,
+    ) -> None: ...
+
+    @typing.overload
+    def set_crs(
+        self,
+        crs: Any | None = ...,
+        epsg: int | None = ...,
+        inplace: Literal[False] = ...,
+        allow_override: bool = ...,
+    ) -> GeoDataFrame: ...
+
+    def set_crs(
+        self,
+        crs: Any | None = None,
+        epsg: int | None = None,
+        inplace: bool = False,
+        allow_override: bool = False,
+    ) -> GeoDataFrame | None:
         """
         Set the Coordinate Reference System (CRS) of the ``GeoDataFrame``.
 
@@ -1631,7 +1756,28 @@ default 'snappy'
         )
         return df
 
-    def to_crs(self, crs=None, epsg=None, inplace=False):
+    @typing.overload
+    def to_crs(
+        self,
+        crs: Any | None = ...,
+        epsg: int | None = ...,
+        inplace: Literal[False] = ...,
+    ) -> GeoDataFrame: ...
+
+    @typing.overload
+    def to_crs(
+        self,
+        crs: Any | None = ...,
+        epsg: int | None = ...,
+        inplace: Literal[True] = ...,
+    ) -> None: ...
+
+    def to_crs(
+        self,
+        crs: Any | None = None,
+        epsg: int | None = None,
+        inplace: bool = False,
+    ) -> GeoDataFrame | None:
         """Transform geometries to a new coordinate reference system.
 
         Transform all geometries in an active geometry column to a different coordinate
@@ -1716,7 +1862,7 @@ default 'snappy'
         if not inplace:
             return df
 
-    def estimate_utm_crs(self, datum_name="WGS 84"):
+    def estimate_utm_crs(self, datum_name: str = "WGS 84") -> CRS:
         """Returns the estimated UTM CRS based on the bounds of the dataset.
 
         .. versionadded:: 0.9
@@ -1785,7 +1931,7 @@ default 'snappy'
                 result.__class__ = DataFrame
         return result
 
-    def _persist_old_default_geometry_colname(self):
+    def _persist_old_default_geometry_colname(self) -> None:
         """Internal util to temporarily persist the default geometry column
         name of 'geometry' for backwards compatibility."""
         # self.columns check required to avoid this warning in __init__
@@ -1834,7 +1980,7 @@ default 'snappy'
     # Implement pandas methods
     #
     @doc(pd.DataFrame)
-    def copy(self, deep=True):
+    def copy(self, deep: bool = True) -> GeoDataFrame:
         copied = super().copy(deep=deep)
         if type(copied) is pd.DataFrame:
             copied.__class__ = GeoDataFrame
@@ -1842,7 +1988,15 @@ default 'snappy'
         return copied
 
     @doc(pd.DataFrame)
-    def apply(self, func, axis=0, raw=False, result_type=None, args=(), **kwargs):
+    def apply(
+        self,
+        func,
+        axis=0,
+        raw: bool = False,
+        result_type=None,
+        args=(),
+        **kwargs,
+    ):
         result = super().apply(
             func, axis=axis, raw=raw, result_type=result_type, args=args, **kwargs
         )
@@ -1874,10 +2028,10 @@ default 'snappy'
         return result
 
     @property
-    def _constructor(self):
+    def _constructor(self) -> DataFrame | GeoDataFrame:
         return _geodataframe_constructor_with_fallback
 
-    def _constructor_from_mgr(self, mgr, axes):
+    def _constructor_from_mgr(self, mgr, axes) -> DataFrame | GeoDataFrame:
         # replicate _geodataframe_constructor_with_fallback behaviour
         # unless safe to skip
         if not any(isinstance(block.dtype, GeometryDtype) for block in mgr.blocks):
@@ -1892,7 +2046,7 @@ default 'snappy'
         return gdf
 
     @property
-    def _constructor_sliced(self):
+    def _constructor_sliced(self) -> Series | GeoSeries:
         def _geodataframe_constructor_sliced(*args, **kwargs):
             """
             A specialized (Geo)Series constructor which can fall back to a
@@ -1917,14 +2071,16 @@ default 'snappy'
 
         return _geodataframe_constructor_sliced
 
-    def _constructor_sliced_from_mgr(self, mgr, axes):
+    def _constructor_sliced_from_mgr(self, mgr, axes) -> Series | GeoSeries:
         is_row_proxy = mgr.index.is_(self.columns)
 
         if isinstance(mgr.blocks[0].dtype, GeometryDtype) and not is_row_proxy:
             return GeoSeries._from_mgr(mgr, axes)
         return Series._from_mgr(mgr, axes)
 
-    def __finalize__(self, other, method=None, **kwargs):
+    def __finalize__(
+        self, other, method: str | None = None, **kwargs
+    ) -> GeoDataFrame | GeoSeries:
         """propagate metadata from other to self"""
         self = super().__finalize__(other, method=method, **kwargs)  # noqa: PLW0642
 
@@ -1962,17 +2118,17 @@ default 'snappy'
 
     def dissolve(
         self,
-        by=None,
+        by: str | None = None,
         aggfunc="first",
-        as_index=True,
+        as_index: bool = True,
         level=None,
-        sort=True,
-        observed=False,
-        dropna=True,
-        method="unary",
-        grid_size=None,
+        sort: bool = True,
+        observed: bool = False,
+        dropna: bool = True,
+        method: Literal["unary", "coverage"] = "unary",
+        grid_size: float | None = None,
         **kwargs,
-    ):
+    ) -> GeoDataFrame:
         """
         Dissolve geometries within `groupby` into single observation.
         This is accomplished by applying the `union_all` method
@@ -2076,7 +2232,7 @@ default 'snappy'
         """
 
         if by is None and level is None:
-            by = np.zeros(len(self), dtype="int64")
+            by = np.zeros(len(self), dtype="int64")  # type: ignore [assignment]
 
         groupby_kwargs = {
             "by": by,
@@ -2113,7 +2269,13 @@ default 'snappy'
         return aggregated
 
     # overrides the pandas native explode method to break up features geometrically
-    def explode(self, column=None, ignore_index=False, index_parts=False, **kwargs):
+    def explode(
+        self,
+        column: str | None = None,
+        ignore_index: bool = False,
+        index_parts: bool = False,
+        **kwargs,
+    ) -> GeoDataFrame | DataFrame:
         """
         Explode multi-part geometries into multiple single geometries.
 
@@ -2220,15 +2382,15 @@ default 'snappy'
 
     def to_postgis(
         self,
-        name,
+        name: str,
         con,
-        schema=None,
-        if_exists="fail",
-        index=False,
-        index_label=None,
-        chunksize=None,
+        schema: str | None = None,
+        if_exists: Literal["fail", "replace", "append"] = "fail",
+        index: bool = False,
+        index_label: Iterable[str] | str | None = None,
+        chunksize: int | None = None,
         dtype=None,
-    ):
+    ) -> None:
         """
         Upload GeoDataFrame into PostGIS database.
 
@@ -2289,10 +2451,18 @@ default 'snappy'
     plot = Accessor("plot", geopandas.plotting.GeoplotAccessor)
 
     @doc(_explore)
-    def explore(self, *args, **kwargs):
+    def explore(self, *args, **kwargs) -> folium.Map:
         return _explore(self, *args, **kwargs)
 
-    def sjoin(self, df, *args, **kwargs):
+    def sjoin(
+        self,
+        df: GeoDataFrame,
+        how: Literal["left", "right", "inner"] = "inner",
+        predicate: str = "intersects",
+        lsuffix: str = "left",
+        rsuffix: str = "right",
+        **kwargs,
+    ) -> GeoDataFrame:
         """Spatial join of two GeoDataFrames.
 
         See the User Guide page :doc:`../../user_guide/mergingdata` for details.
@@ -2376,18 +2546,26 @@ default 'snappy'
         GeoDataFrame.sjoin_nearest : nearest neighbor join
         sjoin : equivalent top-level function
         """
-        return geopandas.sjoin(left_df=self, right_df=df, *args, **kwargs)  # noqa: B026
+        return geopandas.sjoin(
+            left_df=self,
+            right_df=df,
+            how=how,
+            predicate=predicate,
+            lsuffix=lsuffix,
+            rsuffix=rsuffix,
+            **kwargs,
+        )
 
     def sjoin_nearest(
         self,
-        right,
-        how="inner",
-        max_distance=None,
-        lsuffix="left",
-        rsuffix="right",
-        distance_col=None,
-        exclusive=False,
-    ):
+        right: GeoDataFrame,
+        how: Literal["left", "right", "inner"] = "inner",
+        max_distance: float | None = None,
+        lsuffix: str = "left",
+        rsuffix: str = "right",
+        distance_col: str | None = None,
+        exclusive: bool = False,
+    ) -> GeoDataFrame:
         """
         Spatial join of two GeoDataFrames based on the distance between their
         geometries.
@@ -2427,7 +2605,6 @@ default 'snappy'
         exclusive : bool, optional, default False
             If True, the nearest geometries that are equal to the input geometry
             will not be returned, default False.
-            Requires Shapely >= 2.0
 
         Examples
         --------
@@ -2513,7 +2690,9 @@ chicago_w_groceries[chicago_w_groceries["community"] == "UPTOWN"]
             exclusive=exclusive,
         )
 
-    def clip(self, mask, keep_geom_type=False, sort=False):
+    def clip(
+        self, mask, keep_geom_type: bool = False, sort: bool = False
+    ) -> GeoDataFrame:
         """Clip points, lines, or polygon geometries to the mask extent.
 
         Both layers must be in the same Coordinate Reference System (CRS).
@@ -2572,7 +2751,15 @@ chicago_w_groceries[chicago_w_groceries["community"] == "UPTOWN"]
         """
         return geopandas.clip(self, mask=mask, keep_geom_type=keep_geom_type, sort=sort)
 
-    def overlay(self, right, how="intersection", keep_geom_type=None, make_valid=True):
+    def overlay(
+        self,
+        right: GeoDataFrame,
+        how: Literal[
+            "intersection", "union", "identity", "symmetric_difference", "difference"
+        ] = "intersection",
+        keep_geom_type: bool | None = None,
+        make_valid: bool = True,
+    ):
         """Perform spatial overlay between GeoDataFrames.
 
         Currently only supports data GeoDataFrames with uniform geometry types,
@@ -2665,7 +2852,13 @@ chicago_w_groceries[chicago_w_groceries["community"] == "UPTOWN"]
         )
 
 
-def _dataframe_set_geometry(self, col, drop=None, inplace=False, crs=None):
+def _dataframe_set_geometry(
+    self,
+    col,
+    drop: bool | None = None,
+    inplace: Literal[False] = False,
+    crs: Any | None = None,
+) -> GeoDataFrame:
     if inplace:
         raise ValueError(
             "Can't do inplace setting when converting from DataFrame to GeoDataFrame"
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index f1169cb4..9dfeee41 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -1,6 +1,7 @@
 import json
 import warnings
 from packaging.version import Version
+from typing import Literal, get_args
 
 import numpy as np
 from pandas import DataFrame, Series
@@ -15,7 +16,8 @@ from geopandas.array import from_shapely, from_wkb
 from .file import _expand_user
 
 METADATA_VERSION = "1.0.0"
-SUPPORTED_VERSIONS = ["0.1.0", "0.4.0", "1.0.0-beta.1", "1.0.0", "1.1.0"]
+SUPPORTED_VERSIONS_LITERAL = Literal["0.1.0", "0.4.0", "1.0.0-beta.1", "1.0.0", "1.1.0"]
+SUPPORTED_VERSIONS = list(get_args(SUPPORTED_VERSIONS_LITERAL))
 GEOARROW_ENCODINGS = [
     "point",
     "linestring",
@@ -25,6 +27,7 @@ GEOARROW_ENCODINGS = [
     "multipolygon",
 ]
 SUPPORTED_ENCODINGS = ["WKB"] + GEOARROW_ENCODINGS
+PARQUET_GEOMETRY_ENCODINGS = Literal["WKB", "geoarrow"]
 
 # reference: https://github.com/opengeospatial/geoparquet
 
diff --git a/geopandas/sindex.py b/geopandas/sindex.py
index 10c11a07..159539c4 100644
--- a/geopandas/sindex.py
+++ b/geopandas/sindex.py
@@ -54,20 +54,46 @@ class SpatialIndex:
         return PREDICATES
 
     def query(
-        self, geometry, predicate=None, sort=False, distance=None, output_format="tuple"
+        self,
+        geometry,
+        predicate=None,
+        sort=False,
+        distance=None,
+        output_format="indices",
     ):
         """
-        Return the integer indices of all combinations of each input geometry
+        Return all combinations of each input geometry
         and tree geometries where the bounding box of each input geometry
         intersects the bounding box of a tree geometry.
 
-        If the input geometry is a scalar, this returns an array of shape (n, ) with
-        the indices of the matching tree geometries.  If the input geometry is an
-        array_like, this returns an array with shape (2,n) where the subarrays
-        correspond to the indices of the input geometries and indices of the
-        tree geometries associated with each.  To generate an array of pairs of
-        input geometry index and tree geometry index, simply transpose the
-        result.
+        The result can be returned as an array of 'indices' or a boolean 'sparse' or
+        'dense' array. This can be controlled using the ``output_format`` keyword.
+        Options are as follows.
+
+        ``'indices'``
+            If the input geometry is a scalar, this returns an array of shape (n, ) with
+            the indices of the matching tree geometries.  If the input geometry is an
+            array_like, this returns an array with shape (2,n) where the subarrays
+            correspond to the indices of the input geometries and indices of the
+            tree geometries associated with each.  To generate an array of pairs of
+            input geometry index and tree geometry index, simply transpose the
+            result.
+        ``'sparse'``
+            If the input geometry is a scalar, this returns a boolean scipy.sparse COO
+            array of shape (len(tree), ) with boolean values marking whether the
+            bounding box of a geometry in the tree intersects a bounding box of a given
+            scalar. If the input geometry is an array_like, this returns a boolean
+            scipy.sparse COO array with shape (len(tree), n) with boolean values marking
+            whether the bounding box of a geometry in the tree intersects a bounding box
+            of a given scalar.
+        ``'dense'``
+            If the input geometry is a scalar, this returns a boolean numpy
+            array of shape (len(tree), ) with boolean values marking whether the
+            bounding box of a geometry in the tree intersects a bounding box of a given
+            scalar. If the input geometry is an array_like, this returns a boolean
+            numpy array with shape (len(tree), n) with boolean values marking
+            whether the bounding box of a geometry in the tree intersects a bounding box
+            of a given scalar.
 
         If a predicate is provided, the tree geometries are first queried based
         on the bounding box of the input geometry and then are further filtered
@@ -106,22 +132,56 @@ class SpatialIndex:
             as the secondary key.
             If False, no additional sorting is applied (results are often
             sorted but there is no guarantee).
+            Applicable only if output_format="indices".
         distance : number or array_like, optional
             Distances around each input geometry within which to query the tree for
             the 'dwithin' predicate. If array_like, shape must be broadcastable to shape
             of geometry. Required if ``predicate='dwithin'``.
+        output_format : {"indices", "sparse", "dense"}, default "indices"
+            Type of the output format representing the result of the query.
 
         Returns
         -------
-        ndarray with shape (n,) if geometry is a scalar
+        `If geometry is a scalar:`
+
+        ndarray with shape (n,)
             Integer indices for matching geometries from the spatial index
-            tree geometries.
+            tree geometries.  If ``output_format="indices"``.
 
         OR
 
-        ndarray with shape (2, n) if geometry is an array_like
+        scipy.sparse COO array with shape (len(tree), )
+            Boolean array aligned with array of geometries in the tree.
+            If ``output_format="sparse"``.
+
+        OR
+
+        ndarray with shape (len(tree), )
+            Boolean array aligned with array of geometries in the tree.
+            If ``output_format="dense"``.
+
+
+        `If geometry is an array_like:`
+
+        ndarray with shape (2, n)
             The first subarray contains input geometry integer indices.
             The second subarray contains tree geometry integer indices.
+            If ``output_format="indices"``.
+
+        OR
+
+        scipy.sparse COO array with shape (len(tree), n)
+            Boolean array aligned with array of geometries in the tree along axis 0 and
+            with ``geometry`` along axis 1.
+            If ``output_format="sparse"``.
+
+        OR
+
+        ndarray with shape (len(tree), n)
+            Boolean array aligned with array of geometries in the tree along axis 0 and
+            with ``geometry`` along axis 1.
+            If ``output_format="dense"``.
+
 
         Examples
         --------
@@ -170,6 +230,32 @@ class SpatialIndex:
         >>> s.sindex.query(box(1, 1, 3, 3), predicate="dwithin", distance=2)
         array([0, 1, 2, 3, 4])
 
+        Returning boolean arrays:
+
+        >>> s.sindex.query(box(1, 1, 3, 3), output_format="sparse")
+        <COOrdinate sparse array of dtype 'bool'
+            with 3 stored elements and shape (10,)>
+
+        >>> s.sindex.query(box(1, 1, 3, 3), output_format="dense")
+        array([False,  True,  True,  True, False, False, False, False, False,
+               False])
+
+        >>> s.sindex.query(s2, output_format="sparse")
+        <COOrdinate sparse array of dtype 'bool'
+            with 5 stored elements and shape (10, 2)>
+
+        >>> s.sindex.query(s2, output_format="dense")
+        array([[False, False],
+               [False, False],
+               [ True, False],
+               [ True, False],
+               [ True, False],
+               [False,  True],
+               [False,  True],
+               [False, False],
+               [False, False],
+               [False, False]])
+
         Notes
         -----
         In the context of a spatial join, input geometries are the "left"
@@ -211,10 +297,7 @@ class SpatialIndex:
 
         indices = self._tree.query(geometry, predicate=predicate, **kwargs)
 
-        if output_format != "tuple":
-            sort = True
-
-        if sort:
+        if output_format == "indices" and sort:
             if indices.ndim == 1:
                 indices = np.sort(indices)
             else:
@@ -224,23 +307,37 @@ class SpatialIndex:
                 indices = np.vstack((geo_idx[sort_indexer], tree_idx[sort_indexer]))
 
         if output_format == "sparse":
-            from scipy.sparse import coo_array
+            scipy = compat.import_optional_dependency("scipy")
 
-            return coo_array(
-                (np.ones(len(indices[0]), dtype=np.bool_), indices),
+            if indices.ndim == 1:
+                return scipy.sparse.coo_array(
+                    (np.ones(len(indices), dtype=np.bool_), indices.reshape(1, -1)),
+                    shape=(len(self.geometries),),
+                    dtype=np.bool_,
+                )
+            return scipy.sparse.coo_array(
+                (np.ones(len(indices[0]), dtype=np.bool_), indices[::-1]),
                 shape=(len(self.geometries), len(geometry)),
                 dtype=np.bool_,
             )
 
         if output_format == "dense":
-            dense = np.zeros((len(self.geometries), len(geometry)), dtype=bool)
-            dense[indices] = True
+            if indices.ndim == 1:
+                dense = np.zeros(len(self.geometries), dtype=bool)
+                dense[indices] = True
+            else:
+                dense = np.zeros((len(self.geometries), len(geometry)), dtype=bool)
+                tree, other = indices[::-1]
+                dense[tree, other] = True
             return dense
 
-        if output_format == "tuple":
+        if output_format == "indices":
             return indices
 
-        raise ValueError(f"Invalid output_format: {output_format}")
+        raise ValueError(
+            f"Invalid output_format: '{output_format}'. "
+            "Use one of 'indices', 'sparse', 'dense'."
+        )
 
     @staticmethod
     def _as_geometry_array(geometry):
diff --git a/pyproject.toml b/pyproject.toml
index 9717a007..d3d964be 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,6 +40,7 @@ all = [
     "folium", # >=0.12 (implied minimum version, but older versions may work)
     "GeoAlchemy2",
     "pyarrow>=10.0.0",
+    "scipy",
 ]
 
 # Minimum supported additional deps, not installed as part of `all` extra
diff --git a/readthedocs.yml b/readthedocs.yml
index 4f6b06fd..91012ad9 100644
--- a/readthedocs.yml
+++ b/readthedocs.yml
@@ -1,4 +1,7 @@
 version: 2
+sphinx:
+  # Path to your Sphinx configuration file.
+  configuration: doc/source/conf.py
 build:
   os: ubuntu-22.04
   tools:


Test patch:
diff --git a/geopandas/tests/test_sindex.py b/geopandas/tests/test_sindex.py
index efaf3801..ab7ea414 100644
--- a/geopandas/tests/test_sindex.py
+++ b/geopandas/tests/test_sindex.py
@@ -19,6 +19,14 @@ from geopandas import _compat as compat
 import pytest
 from numpy.testing import assert_array_equal
 
+try:
+    from scipy.sparse import coo_array  # noqa: F401
+
+    HAS_SCIPY = True
+except ImportError:
+    HAS_SCIPY = False
+SCIPY_MARK = pytest.mark.skipif(not HAS_SCIPY, reason="scipy not installed")
+
 
 class TestSeriesSindex:
     def test_has_sindex(self):
@@ -261,6 +269,9 @@ class TestShapelyInterface:
             self.df.sindex.intersection(test_geom)
 
     # ------------------------------ `query` tests ------------------------------ #
+    @pytest.mark.parametrize(
+        "output_format", ("indices", pytest.param("sparse", marks=SCIPY_MARK), "dense")
+    )
     @pytest.mark.parametrize(
         "predicate, test_geom, expected",
         (
@@ -359,11 +370,22 @@ class TestShapelyInterface:
             ),  # contains but does not contains_properly
         ),
     )
-    def test_query(self, predicate, test_geom, expected):
+    def test_query(self, predicate, test_geom, expected, output_format):
         """Tests the `query` method with valid inputs and valid predicates."""
         res = self.df.sindex.query(test_geom, predicate=predicate)
         assert_array_equal(res, expected)
 
+        if output_format != "indices":
+            dense = np.zeros(len(self.df), dtype=bool)
+            dense[expected] = True
+
+            res = self.df.sindex.query(
+                test_geom, predicate=predicate, output_format=output_format
+            )
+            if output_format == "sparse":
+                res = res.todense()
+            assert_array_equal(res, dense)
+
     def test_query_invalid_geometry(self):
         """Tests the `query` method with invalid geometry."""
         with pytest.raises(TypeError):
@@ -535,7 +557,15 @@ class TestShapelyInterface:
                 )
             raise e
 
+    def test_unsupported_output(self):
+        with pytest.raises(ValueError, match="Invalid output_format: 'dataarray'."):
+            test_geom = box(-1, -1, -0.5, -0.5)
+            self.df.sindex.query(test_geom, output_format="dataarray")
+
     # ------------------------- `query_bulk` tests -------------------------- #
+    @pytest.mark.parametrize(
+        "output_format", ("indices", pytest.param("sparse", marks=SCIPY_MARK), "dense")
+    )
     @pytest.mark.parametrize(
         "predicate, test_geom, expected",
         (
@@ -635,15 +665,26 @@ class TestShapelyInterface:
             ),  # contains but does not contains_properly
         ),
     )
-    def test_query_bulk(self, predicate, test_geom, expected):
+    def test_query_bulk(self, predicate, test_geom, expected, output_format):
         """Tests the `query` method with valid
         inputs and valid predicates.
         """
-        res = self.df.sindex.query(
-            [box(*geom) for geom in test_geom], predicate=predicate
-        )
+        test_geoms = [box(*geom) for geom in test_geom]
+        res = self.df.sindex.query(test_geoms, predicate=predicate)
         assert_array_equal(res, expected)
 
+        if output_format != "indices":
+            dense = np.zeros((len(self.df), len(test_geoms)), dtype=bool)
+            tree, other = expected[::-1]
+            dense[tree, other] = True
+
+            res = self.df.sindex.query(
+                test_geoms, predicate=predicate, output_format=output_format
+            )
+            if output_format == "sparse":
+                res = res.todense()
+            assert_array_equal(res, dense)
+
     @pytest.mark.parametrize(
         "test_geoms, expected_value",
         [