Skip to content

Commit

Permalink
Added support to exchange Vec<Geometry> with python (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
nmandery authored Sep 5, 2022
1 parent c5e7afe commit 1ec8784
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 46 deletions.
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.
* 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

0 comments on commit 1ec8784

Please sign in to comment.