Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rudimentary support for geopandas geoseries #5

Merged
merged 12 commits into from
Sep 5, 2022
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ jobs:
lint:
strategy:
matrix:
os: [ ubuntu-latest, windows-latest, macos-latest ]
os: [
ubuntu-latest,
# windows-latest, # disabled as there are no fiona wheels on windows
macos-latest
]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
Expand Down Expand Up @@ -49,14 +53,14 @@ jobs:

- name: Install Python dependencies
run: |
pip install shapely
pip install shapely geopandas

- name: Test with cargo - default features
uses: actions-rs/[email protected]
with:
command: test
toolchain: stable
args: --features test
args: --features test,wkb

- name: Test with cargo - all features
uses: actions-rs/[email protected]
Expand Down
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

* Rename `GeometryInterface` struct to `Geometry` - that is the last rename of this struct - promised.
Copy link

Choose a reason for hiding this comment

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

:shipit:

Copy link
Owner Author

Choose a reason for hiding this comment

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

Yes, its is impossible to pack everything into a name ;)

* Added support to exchange `Vec<Geometry>` with python.

## 0.3.0

* Support exchanging geometries using Well-Known-Binary format. The `wkb`-property of `shapely`
geometries will be used. Additionally, the `GeometryInterface`-type exposed to python will have a `wkb`-property
itself. This is only supported for the `f64` variant of the `GeoInterface`.
Expand Down
75 changes: 68 additions & 7 deletions src/from_py.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use geo_types::{
};
use num_traits::NumCast;
use pyo3::exceptions::PyValueError;
use pyo3::types::{PyDict, PyFloat, PyInt, PyList, PyString, PyTuple};
use pyo3::types::{PyDict, PyFloat, PyInt, PyIterator, PyList, PyString, PyTuple};
use pyo3::{intern, PyAny, PyErr, PyResult};
use std::any::type_name;

Expand Down Expand Up @@ -184,14 +184,58 @@ impl<T: PyCoordNum> AsGeometry<T> for PyDict {
}
}

pub trait AsGeometryVec<T: PyCoordNum> {
/// Creates a `Vec<Geometry<T>` from `self`
fn as_geometry_vec(&self) -> PyResult<Vec<Geometry<T>>>;
}

impl<T: PyCoordNum> AsGeometryVec<T> for PyIterator {
fn as_geometry_vec(&self) -> PyResult<Vec<Geometry<T>>> {
let mut outvec = Vec::with_capacity(self.len().unwrap_or(0));
for maybe_geom in self {
outvec.push(maybe_geom?.as_geometry()?);
}
outvec.shrink_to_fit();
Ok(outvec)
}
}

impl<T: PyCoordNum> AsGeometryVec<T> for PyAny {
fn as_geometry_vec(&self) -> PyResult<Vec<Geometry<T>>> {
if let Ok(dict) = self.downcast::<PyDict>() {
// geopandas GeoSeries are exposed to __geo_interface__ as FeatureCollections
let features = extract_dict_value(dict, intern!(dict.py(), "features"))?;
let mut geometries = vec![];
for feature in features.iter()? {
let feature = feature?.downcast::<PyDict>()?;
let geometry = extract_dict_value(feature, intern!(feature.py(), "geometry"))?;
geometries.push(geometry.as_geometry()?)
}
Ok(geometries)
} else {
self.iter()?.as_geometry_vec()
}
}
}

impl<T: PyCoordNum> AsGeometryVec<T> for PyList {
fn as_geometry_vec(&self) -> PyResult<Vec<Geometry<T>>> {
let mut outvec = Vec::with_capacity(self.len());
for maybe_geom in self {
outvec.push(maybe_geom.as_geometry()?);
}
Ok(outvec)
}
}

fn extract_geometry<T: PyCoordNum>(dict: &PyDict, level: u8) -> PyResult<Geometry<T>> {
if level > 1 {
Err(PyValueError::new_err("recursion level exceeded"))
} else {
let geom_type = extract_geom_dict_value(dict, intern!(dict.py(), "type"))?
let geom_type = extract_dict_value(dict, intern!(dict.py(), "type"))?
.downcast::<PyString>()?
.extract::<String>()?;
let coordinates = || extract_geom_dict_value(dict, intern!(dict.py(), "coordinates"));
let coordinates = || extract_dict_value(dict, intern!(dict.py(), "coordinates"));
match geom_type.as_str() {
"Point" => Ok(Geometry::from(Point::from(coordinates()?.as_coordinate()?))),
"MultiPoint" => Ok(Geometry::from(MultiPoint::from(
Expand Down Expand Up @@ -219,7 +263,7 @@ fn extract_geometry<T: PyCoordNum>(dict: &PyDict, level: u8) -> PyResult<Geometr
)?))),
"GeometryCollection" => {
let geoms = tuple_map(
extract_geom_dict_value(dict, intern!(dict.py(), "geometries"))?,
extract_dict_value(dict, intern!(dict.py(), "geometries"))?,
|tuple| {
tuple
.iter()
Expand Down Expand Up @@ -261,12 +305,12 @@ fn extract_polygon<T: PyCoordNum>(obj: &PyAny) -> PyResult<Polygon<T>> {
Ok(Polygon::new(exterior, linestings))
}

fn extract_geom_dict_value<'a>(dict: &'a PyDict, key: &PyString) -> PyResult<&'a PyAny> {
fn extract_dict_value<'a>(dict: &'a PyDict, key: &PyString) -> PyResult<&'a PyAny> {
if let Some(value) = dict.get_item(key) {
Ok(value)
} else {
Err(PyValueError::new_err(format!(
"geometry has \"{}\" not set",
"dict has \"{}\" not set",
key
)))
}
Expand Down Expand Up @@ -309,7 +353,7 @@ mod tests {
//! most data used in these testcases is from the GeoJSON RFC
//! https://datatracker.ietf.org/doc/html/rfc7946
//!
use crate::from_py::{AsCoordinate, AsCoordinateVec, AsGeometry};
use crate::from_py::{AsCoordinate, AsCoordinateVec, AsGeometry, AsGeometryVec};
use geo_types::{
Coordinate, Geometry, GeometryCollection, LineString, MultiPoint, MultiPolygon, Point,
Polygon,
Expand Down Expand Up @@ -607,4 +651,21 @@ class Something:
.unwrap();
assert_eq!(geom, Geometry::Point(Point::new(5., 3.)));
}

#[test]
fn geometries_from_geopandas_geoseries() {
let geometries: Vec<Geometry<f64>> = Python::with_gil(|py| {
py.run(
r#"
import geopandas as gpd
world = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'))
"#,
None,
None,
)?;
py.eval(r#"world.geometry"#, None, None)?.as_geometry_vec()
})
.unwrap();
assert!(geometries.len() > 100);
}
}
32 changes: 18 additions & 14 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,28 @@
//!
//! The `__geo_interface__` protocol is implemented by most popular geospatial python modules like `shapely`, `geojson`, `geopandas`, ....
//!
//! The main struct of this crate is [`GeometryInterface`]. This the docs there for usage examples.
//! The main struct of this crate is [`Geometry`]. This the docs there for usage examples.
//!
//! ## Features
//!
//! As rust types exposed to python may not have generic type parameters, there are multiple implementations of the `GeometryInterface` type based
//! As rust types exposed to python may not have generic type parameters, there are multiple implementations of the `Geometry` type based
//! on different types for the coordinate values. The default is `f64`, other types can be enabled using the `f32`, `u8`, `u16`, `u32`, `u64`,
//! `i8`, `i16`, `i32` and `i64` feature gates. The implementation are then available as `py_geo_interface::wrappers::[datatype]::GeometryInterface`.
//! The default and probably most common used `f64`-variant is also available as `py_geo_interface::GeometryInterface`.
//! `i8`, `i16`, `i32` and `i64` feature gates. The implementation are then available as `py_geo_interface::wrappers::[datatype]::Geometry`.
//! The default and probably most common used `f64`-variant is also available as `py_geo_interface::Geometry`.
//!
//! The `wkb` feature adds support for exchanging geometries using the Well-Known-Binary format. The `wkb`-property of `shapely`
//! geometries will be used when found. Additionally, the `GeometryInterface`-type exposed to python will have a `wkb`-property
//! itself. WKB is only supported for the `f64`-variant of the `GeometryInterface`, the feature is disabled per default.
//! geometries will be used when found. Additionally, the `Geometry`-type exposed to python will have a `wkb`-property
//! itself. WKB is only supported for the `f64`-variant of the `Geometry`, the feature is disabled per default.
//!
//! ## Examples
//!
//! ### Read python types implementing `__geo_interface__` into `geo-types`:
//!
//! #[include]
//! ```rust
//! use geo_types::{Geometry, Point};
//! use geo_types::{Geometry as GtGeometry, Point};
//! use pyo3::{prepare_freethreaded_python, Python};
//! use py_geo_interface::GeometryInterface;
//! use py_geo_interface::Geometry;
//!
//! prepare_freethreaded_python();
//!
Expand All @@ -39,25 +39,25 @@
//! "#, None, None).unwrap();
//!
//! // create an instance of the class and extract the geometry
//! py.eval(r#"Something()"#, None, None)?.extract::<GeometryInterface>()
//! py.eval(r#"Something()"#, None, None)?.extract::<Geometry>()
//! }).unwrap();
//! assert_eq!(geom.0, Geometry::Point(Point::new(5.0_f64, 3.0_f64)));
//! assert_eq!(geom.0, GtGeometry::Point(Point::new(5.0_f64, 3.0_f64)));
//! ```
//!
//! ### Pass geometries from Rust to Python:
//!
//! ```rust
//! use geo_types::{Geometry, Point};
//! use geo_types::{Geometry as GtGeometry, Point};
//! use pyo3::{prepare_freethreaded_python, Python};
//! use pyo3::types::{PyDict, PyTuple};
//! use pyo3::IntoPy;
//! use py_geo_interface::GeometryInterface;
//! use py_geo_interface::Geometry;
//!
//! prepare_freethreaded_python();
//!
//! Python::with_gil(|py| {
//!
//! let geom: GeometryInterface = Point::new(10.6_f64, 23.3_f64).into();
//! let geom: Geometry = Point::new(10.6_f64, 23.3_f64).into();
//! let mut locals = PyDict::new(py);
//! locals.set_item("geom", geom.into_py(py)).unwrap();
//!
Expand Down Expand Up @@ -100,4 +100,8 @@ impl<T: CoordNum + IntoPy<Py<PyAny>> + ExtractFromPyFloat + ExtractFromPyInt + W
impl<T: CoordNum + IntoPy<Py<PyAny>> + ExtractFromPyFloat + ExtractFromPyInt> PyCoordNum for T {}

#[cfg(feature = "f64")]
pub use crate::wrappers::f64::GeometryInterface;
pub use crate::wrappers::f64::Geometry;
#[cfg(feature = "f64")]
pub use crate::wrappers::f64::GeometryVec;
#[cfg(feature = "f64")]
pub use crate::wrappers::f64::GeometryVecFc;
112 changes: 111 additions & 1 deletion src/to_py.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use geo_types::{
Coordinate, Geometry, GeometryCollection, Line, LineString, MultiLineString, MultiPoint,
MultiPolygon, Point, Polygon,
};
use pyo3::types::{PyDict, PyString, PyTuple};
use pyo3::types::{PyDict, PyList, PyString, PyTuple};
use pyo3::{intern, PyObject, PyResult, Python, ToPyObject};
use std::iter::once;

Expand Down Expand Up @@ -184,3 +184,113 @@ where
PyTuple::new(py, self.iter().map(|c| c.to_py(py))).to_object(py)
}
}

pub trait AsGeoInterfaceList {
/// return self as a python list of `__geo_interface__`-representations of geometries
fn as_geointerface_list_pyobject(&self, py: Python) -> PyResult<PyObject>;
}

impl<T> AsGeoInterfaceList for &[Geometry<T>]
where
T: PyCoordNum,
{
fn as_geointerface_list_pyobject(&self, py: Python) -> PyResult<PyObject> {
let geometries = self
.iter()
.map(|g| g.as_geointerface_pyobject(py))
.collect::<PyResult<Vec<_>>>()?;
Ok(PyList::new(py, geometries).to_object(py))
}
}

impl<T> AsGeoInterfaceList for Vec<Geometry<T>>
where
T: PyCoordNum,
{
fn as_geointerface_list_pyobject(&self, py: Python) -> PyResult<PyObject> {
self.as_slice().as_geointerface_list_pyobject(py)
}
}

pub trait AsGeoInterfaceFeatureCollection {
/// return self as a python `__geo_interface__` FeatureCollection
fn as_geointerface_featurecollection_pyobject(&self, py: Python) -> PyResult<PyObject>;
}

impl<T> AsGeoInterfaceFeatureCollection for &[Geometry<T>]
where
T: PyCoordNum,
{
fn as_geointerface_featurecollection_pyobject(&self, py: Python) -> PyResult<PyObject> {
let featurecollection = PyDict::new(py);
featurecollection.set_item(intern!(py, "type"), intern!(py, "FeatureCollection"))?;

let features = self
.iter()
.map(|geom| geom_as_py_feature(py, geom))
.collect::<PyResult<Vec<_>>>()?;

featurecollection.set_item(intern!(py, "features"), features)?;
Ok(featurecollection.to_object(py))
}
}

impl<T> AsGeoInterfaceFeatureCollection for Vec<Geometry<T>>
where
T: PyCoordNum,
{
fn as_geointerface_featurecollection_pyobject(&self, py: Python) -> PyResult<PyObject> {
self.as_slice()
.as_geointerface_featurecollection_pyobject(py)
}
}

fn geom_as_py_feature<T>(py: Python, geom: &Geometry<T>) -> PyResult<PyObject>
where
T: PyCoordNum,
{
let feature = PyDict::new(py);
feature.set_item(intern!(py, "type"), intern!(py, "Feature"))?;
feature.set_item(intern!(py, "properties"), PyDict::new(py))?;
feature.set_item(intern!(py, "geometry"), geom.as_geointerface_pyobject(py)?)?;
Ok(feature.to_object(py))
}

#[cfg(all(test, feature = "f64"))]
mod tests {
use crate::wrappers::f64::GeometryVecFc;
use geo_types::{Geometry as GtGeometry, Point};
use pyo3::types::PyDict;
use pyo3::{IntoPy, Python};

#[test]
fn geopandas_from_features() {
let geometries: GeometryVecFc = vec![
GtGeometry::Point(Point::new(1.0f64, 3.0)),
GtGeometry::Point(Point::new(2.0, 6.0)),
]
.into();

Python::with_gil(|py| {
let locals = PyDict::new(py);
locals
.set_item("feature_collection", geometries.into_py(py))
.unwrap();

py.run(
r#"
import geopandas as gpd
from shapely.geometry import Point

gdf = gpd.GeoDataFrame.from_features(feature_collection)
assert len(gdf) == 2
assert gdf.geometry[0] == Point(1,3)
assert gdf.geometry[1] == Point(2,6)
"#,
None,
Some(locals),
)
.unwrap();
});
}
}
Loading