Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions python/sedonadb-geopandas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!---
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->

# sedonadb-geopandas

A GeoPandas-compatible API on top of [SedonaDB](https://sedona.apache.org/sedonadb/).

The goal is to let existing GeoPandas code run against SedonaDB's relational
engine with minimal changes, by providing `GeoDataFrame` / `GeoSeries` wrappers
whose methods mirror GeoPandas but delegate to SedonaDB expressions.

```python
import geopandas
import sedonadb_geopandas as sgpd

gdf = sgpd.from_geopandas(geopandas.read_file("cities.geojson"))
big = gdf[gdf["pop"] > 1_000_000] # boolean-mask filter
buffered = gdf.geometry.buffer(0.5) # element-wise .geo operation
web = gdf.to_crs("EPSG:3857") # reproject (CRS tracked through)
result = web.to_geopandas() # back to a real GeoDataFrame
```

## Intentional differences from GeoPandas

This is a compatibility layer over a lazy, relational engine, so it is
deliberately *not* identical to GeoPandas:

- **Lazy, not eager**: operations build a query; data materializes on
`to_geopandas()` / `to_pandas()` / display.
- **No row index / alignment**: there is no pandas `Index`; joins and filters
are positional/relational, not index-aligned.
- **Immutable under the hood**: "in-place" style operations return a new frame.
- **Plotting and arbitrary `apply`**: use the `to_geopandas()` escape hatch and
operate on the materialized result.

See the SedonaDB "Migrating from GeoPandas" guide for the relational model that
underlies each method.
55 changes: 55 additions & 0 deletions python/sedonadb-geopandas/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "sedonadb-geopandas"
version = "0.4.0"
description = "GeoPandas-compatible API on top of SedonaDB"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
Comment thread
paleolimbot marked this conversation as resolved.
"sedonadb",
"sedonadb-expr",
"pyarrow",
]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
]

[project.optional-dependencies]
# geopandas is only needed for the from_geopandas / to_geopandas interop
# helpers, so it is an opt-in extra rather than a hard dependency.
geopandas = ["geopandas"]
test = [
"pytest",
"pandas",
"geopandas",
]

[tool.hatch.build.targets.wheel]
packages = ["python/sedonadb_geopandas"]
54 changes: 54 additions & 0 deletions python/sedonadb-geopandas/python/sedonadb_geopandas/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""GeoPandas-compatible API on top of SedonaDB.

**EXPERIMENTAL.** This package provides `GeoDataFrame` / `GeoSeries` wrappers
whose methods mirror GeoPandas but delegate to a lazy SedonaDB engine. It is a
compatibility layer, not a drop-in replacement: see the package README for the
intentional differences (laziness, no row index, immutability). The API may
change without notice.
"""
Comment thread
paleolimbot marked this conversation as resolved.

from sedonadb_geopandas._context import default_context
from sedonadb_geopandas._frame import GeoDataFrame
from sedonadb_geopandas._series import GeoSeries, Series

__all__ = ["GeoDataFrame", "GeoSeries", "Series", "from_geopandas"]


def from_geopandas(data, *, context=None, geometry=None):
"""Load a `geopandas.GeoDataFrame` into a SedonaDB-backed `GeoDataFrame`.

**EXPERIMENTAL.**

Args:
data: A `geopandas.GeoDataFrame` (or any object accepted by
`SedonaContext.create_data_frame`, which surfaces an error for
anything that cannot be turned into a DataFrame).
context: An optional SedonaDB context. Defaults to a shared,
lazily-created one.
geometry: The active geometry column name. Defaults to SedonaDB's
primary-geometry heuristic (the same one `to_geopandas` uses).

Returns:
A `GeoDataFrame`.
"""
ctx = context or default_context()
df = ctx.create_data_frame(data)
if geometry is None:
return GeoDataFrame(df)
return GeoDataFrame(df, geometry=geometry)
34 changes: 34 additions & 0 deletions python/sedonadb-geopandas/python/sedonadb_geopandas/_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Default SedonaDB context.

GeoPandas has no notion of a connection, so this package lazily creates a single
shared SedonaDB context the first time it is needed. Callers that want an
explicit context can always pass one through the public constructors.
"""

_DEFAULT = None


def default_context():
"""Return the process-wide default SedonaDB context, creating it if needed."""
global _DEFAULT
if _DEFAULT is None:
import sedonadb

_DEFAULT = sedonadb.connect()
return _DEFAULT
174 changes: 174 additions & 0 deletions python/sedonadb-geopandas/python/sedonadb_geopandas/_frame.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""GeoPandas-style GeoDataFrame backed by a lazy SedonaDB frame."""

from sedonadb_geopandas._series import GeoSeries, Series

# Rows to collect for the Jupyter rich-text (`_repr_html_`) preview.
_REPR_HTML_ROWS = 10

# Default for the `geometry` argument, distinguishing "not specified, apply the
# heuristic" from an explicit `None` meaning "this frame has no active geometry".
_DERIVE = object()


def _geometry_column_names(df):
names = df.schema.names
return {names[i] for i in df.schema.geometry_column_indices}


class GeoDataFrame:
"""A lazy SedonaDB frame in the shape of a `geopandas.GeoDataFrame`.

**EXPERIMENTAL.** Wraps a SedonaDB `DataFrame` and tracks the active
geometry column. Row selection, column access, and geometry operations
mirror GeoPandas but build a query rather than computing eagerly; call
`to_geopandas()` to materialize.
"""

def __init__(self, df, geometry=_DERIVE):
self._df = df
if geometry is _DERIVE:
# Fall back to SedonaDB's primary-geometry heuristic (same one
# `to_geopandas` uses); `None` when the frame has no geometry.
geometry = df._impl.primary_geometry_column()
elif geometry is not None and geometry not in _geometry_column_names(df):
if geometry not in df.schema.names:
raise KeyError(
f"Geometry column {geometry!r} not found; columns: "
f"{df.schema.names}"
)
raise ValueError(f"Column {geometry!r} is not a geometry column")
self._geometry_name = geometry

@property
def geometry(self):
"""The active geometry column as a `GeoSeries`."""
if self._geometry_name is None:
raise AttributeError("This GeoDataFrame has no active geometry column")
return GeoSeries(self._df, self._df[self._geometry_name], self._geometry_name)

@property
def crs(self):
"""The CRS of the active geometry column, or `None` if there is none."""
if self._geometry_name is None:
return None
return self._df.schema.field(self._geometry_name).type.crs

@property
def columns(self):
"""Column names, mirroring `GeoDataFrame.columns`."""
return list(self._df.schema.names)

def __getitem__(self, key):
# Boolean mask -> row filter (gdf[gdf["pop"] > 1000]).
if isinstance(key, Series):
return GeoDataFrame(self._df.filter(key._expr), self._geometry_name)

# Column subset -> GeoDataFrame. Matching GeoPandas, the active geometry
# column is persisted when it survives the subset (rather than being
# re-derived, which could silently pick a different geometry column) and
# is dropped when it does not. GeoPandas returns a plain DataFrame in
# that case; here the result keeps its type but has no active geometry,
# so `.geometry` raises just as it does there.
if isinstance(key, list):
geometry = self._geometry_name if self._geometry_name in key else None
return GeoDataFrame(self._df.select(*key), geometry)

# Single column -> (Geo)Series.
if isinstance(key, str):
expr = self._df[key]
if key == self._geometry_name:
return GeoSeries(self._df, expr, key)
return Series(self._df, expr, key)
Comment on lines +92 to +97

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do integers or slices work here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither did anything useful — both fell through to a generic TypeError. Now handled explicitly, following GeoPandas where it is well defined:

  • an integer key raises KeyError, since in GeoPandas/pandas an integer is a column label rather than a row position (g[0] raises KeyError: 0),
  • a slice raises TypeError explaining that positional row slicing is not supported: there is no row index and no guaranteed row order, so g[0:2] cannot be answered faithfully.

For the bounded-rows case I added head(n), which is well defined as a limit and matches the method that already exists on the Spark side, with a docstring noting that which rows come back is not guaranteed without an ordering. Happy to make slices work as a best-effort limit instead if that trade seems better, but silently returning arbitrary rows for [0:2] felt like the wrong default.


if isinstance(key, slice):
raise TypeError(
"Positional row slicing isn't supported: this frame has no row "
"index, and row order isn't guaranteed. Use head(n) for a "
"bounded number of rows, or filter on a column."
)

if isinstance(key, int):
# Matches GeoPandas/pandas, where an integer key is a column label.
raise KeyError(
f"Column {key!r} not found (an integer key is a column label, "
f"not a row position). Columns: {self.columns}"
)

raise TypeError(
f"GeoDataFrame indices must be a column name, list of names, or "
f"boolean mask, not {type(key).__name__}"
)

def head(self, n=5):
"""Return a `GeoDataFrame` of at most `n` rows.

Note that this applies a limit without an ordering, so *which* rows come
back isn't guaranteed — the frame has no inherent row order.
"""
return GeoDataFrame(self._df.limit(n), self._geometry_name)

def to_crs(self, crs):
"""Reproject the geometry column to `crs` (`ST_Transform`)."""
if self._geometry_name is None:
raise ValueError("to_crs() requires an active geometry column")
from sedonadb.expr import lit

transformed = self._df[self._geometry_name].geo.transform(lit(crs))
new_df = self._df.mutate(**{self._geometry_name: transformed})
return GeoDataFrame(new_df, self._geometry_name)

def to_geopandas(self):
"""Execute and return a `geopandas.GeoDataFrame` (or plain DataFrame).

The active geometry column is carried over, so a frame whose geometry
column is not the one SedonaDB's own heuristic would pick (for example a
column named `geom` alongside one named `geometry`) still comes back with
the expected column active.
"""
result = self._df.to_pandas()
if self._geometry_name is not None and hasattr(result, "set_geometry"):
try:
active = result.geometry.name
except Exception:
active = None
if active != self._geometry_name:
result = result.set_geometry(self._geometry_name)
return result

# Alias: results carry geometry, so this returns a GeoDataFrame too.
to_pandas = to_geopandas
Comment thread
paleolimbot marked this conversation as resolved.

def __len__(self):
return self._df.count()

def __repr__(self):
# Cheap: no execution. IDEs/consoles call repr frequently.
return f"GeoDataFrame(columns={self.columns}, geometry={self._geometry_name!r})"

def _repr_html_(self):
# Rich Jupyter display: collect only a small preview.
try:
preview = self._df.limit(_REPR_HTML_ROWS).to_pandas()
table = preview._repr_html_()
except Exception:
return None # fall back to __repr__
return (
f"<div><b>GeoDataFrame</b> (preview of up to "
f"{_REPR_HTML_ROWS} rows)</div>{table}"
)
Loading