diff --git a/Cargo.lock b/Cargo.lock index 9dc5326..57b7d60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,7 +690,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror 2.0.3", + "thiserror 2.0.4", ] [[package]] @@ -2278,9 +2278,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.22.6" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +checksum = "e484fd2c8b4cb67ab05a318f1fd6fa8f199fcc30819f08f07d200809dba26c15" dependencies = [ "cfg-if", "indoc", @@ -2296,9 +2296,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.22.6" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +checksum = "dc0e0469a84f208e20044b98965e1561028180219e35352a2afaf2b942beff3b" dependencies = [ "once_cell", "target-lexicon", @@ -2306,9 +2306,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.22.6" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +checksum = "eb1547a7f9966f6f1a0f0227564a9945fe36b90da5a93b3933fc3dc03fae372d" dependencies = [ "libc", "pyo3-build-config", @@ -2316,9 +2316,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.22.6" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +checksum = "fdb6da8ec6fa5cedd1626c886fc8749bdcbb09424a86461eb8cdf096b7c33257" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -2328,9 +2328,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.22.6" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +checksum = "38a385202ff5a92791168b1136afae5059d3ac118457bb7bc304c197c2d33e7d" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2341,9 +2341,9 @@ dependencies = [ [[package]] name = "pythonize" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90fcf491425978bd889015d5430f6473d91bdfa2097262f1e731aadcf6c2113e" +checksum = "91a6ee7a084f913f98d70cdc3ebec07e852b735ae3059a1500db2661265da9ff" dependencies = [ "pyo3", "serde", @@ -2372,7 +2372,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.3", + "thiserror 2.0.4", "tokio", "tracing", ] @@ -2391,7 +2391,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.3", + "thiserror 2.0.4", "tinyvec", "tracing", "web-time", @@ -2408,7 +2408,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2901,7 +2901,7 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c3c6b7927ffe7ecaa769ee0e3994da3b8cafc8f444578982c83ecb161af917" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.89", @@ -3051,12 +3051,12 @@ dependencies = [ "openssl", "pyo3", "pythonize", - "serde", "serde_json", "stac", "stac-api", "stac-duckdb", "stac-types", + "thiserror 2.0.4", "tokio", ] @@ -3223,11 +3223,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa" +checksum = "2f49a1853cf82743e3b7950f77e0f4d622ca36cf4317cba00c767838bac8d490" dependencies = [ - "thiserror-impl 2.0.3", + "thiserror-impl 2.0.4", ] [[package]] @@ -3243,9 +3243,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" +checksum = "8381894bb3efe0c4acac3ded651301ceee58a15d47c2e34885ed1908ad667061" dependencies = [ "proc-macro2", "quote", @@ -3299,9 +3299,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.41.1" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -3652,7 +3652,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a9f05e0..0059d3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ name = "stacrs" version = "0.3.0" edition = "2021" -publish = false [lib] name = "stacrs" @@ -10,9 +9,8 @@ crate-type = ["cdylib"] [dependencies] geojson = "0.24.1" -pyo3 = "0.22.6" -pythonize = "0.22.0" -serde = "1.0.215" +pyo3 = "0.23.3" +pythonize = "0.23.0" serde_json = "1.0.133" stac = { version = "0.11.0", features = [ "geoparquet-compression", @@ -21,7 +19,8 @@ stac = { version = "0.11.0", features = [ stac-api = { version = "0.6.2", features = ["client"] } stac-duckdb = "0.0.3" stac-types = "0.1.0" -tokio = { version = "1.41.1", features = ["rt"] } +thiserror = "2.0.4" +tokio = { version = "1.42.0", features = ["rt"] } # We don't use duckdb directly, but we need to pin the version to v1.0 for now: https://github.com/stac-utils/stac-rs/issues/385 duckdb = { version = "=1.0.0", features = ["bundled"] } diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..8d69715 --- /dev/null +++ b/scripts/test @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +set -e + +uv run maturin dev --uv && uv run pytest "$@" diff --git a/src/error.rs b/src/error.rs index 283f27c..9fb5545 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,53 +1,40 @@ -use std::convert::Infallible; - use pyo3::{exceptions::PyException, PyErr}; +use thiserror::Error; -pub struct Error(pub String); +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Geojson(#[from] geojson::Error), -impl From for Error { - fn from(value: stac::Error) -> Self { - Error(value.to_string()) - } -} + #[error(transparent)] + Pythonize(#[from] pythonize::PythonizeError), -impl From for Error { - fn from(value: stac_api::Error) -> Self { - Error(value.to_string()) - } -} + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), -impl From for Error { - fn from(value: stac_duckdb::Error) -> Self { - Error(value.to_string()) - } -} + #[error(transparent)] + Stac(#[from] stac::Error), -impl From for Error { - fn from(value: stac_types::Error) -> Self { - Error(value.to_string()) - } -} + #[error(transparent)] + StacApi(#[from] stac_api::Error), -impl From for Error { - fn from(value: geojson::Error) -> Self { - Error(value.to_string()) - } -} + #[error(transparent)] + StacDuckdb(#[from] stac_duckdb::Error), -impl From for Error { - fn from(value: serde_json::Error) -> Self { - Error(value.to_string()) - } -} - -impl From for Error { - fn from(_: Infallible) -> Self { - unreachable!() - } + #[error(transparent)] + StacTypes(#[from] stac_types::Error), } impl From for PyErr { fn from(value: Error) -> Self { - PyException::new_err(value.0) + match value { + Error::Geojson(err) => PyException::new_err(err.to_string()), + Error::Pythonize(err) => PyException::new_err(err.to_string()), + Error::SerdeJson(err) => PyException::new_err(err.to_string()), + Error::Stac(err) => PyException::new_err(err.to_string()), + Error::StacApi(err) => PyException::new_err(err.to_string()), + Error::StacDuckdb(err) => PyException::new_err(err.to_string()), + Error::StacTypes(err) => PyException::new_err(err.to_string()), + } } } diff --git a/src/lib.rs b/src/lib.rs index c992256..de78739 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ use error::Error; use libduckdb_sys as _; use openssl as _; use pyo3::prelude::*; +pub use search::build_search; type Result = std::result::Result; diff --git a/src/migrate.rs b/src/migrate.rs index 3057fcb..ae1ac33 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -11,9 +11,7 @@ pub fn migrate<'py>( let py = value.py(); let value: Value = pythonize::depythonize(value)?; let version = version - .map(|version| version.parse()) - .transpose() - .map_err(Error::from)? + .map(|version| version.parse().unwrap()) .unwrap_or_default(); let value = value.migrate(&version).map_err(Error::from)?; let value = pythonize::pythonize(py, &value)?; @@ -29,9 +27,7 @@ pub fn migrate_href<'py>( ) -> PyResult> { let value: Value = stac::read(href).map_err(Error::from)?; let version = version - .map(|version| version.parse()) - .transpose() - .map_err(Error::from)? + .map(|version| version.parse().unwrap()) .unwrap_or_default(); let value = value.migrate(&version).map_err(Error::from)?; let value = pythonize::pythonize(py, &value)?; diff --git a/src/search.rs b/src/search.rs index 9b395dd..e7e9bdb 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,20 +1,20 @@ use crate::Error; +use geojson::Geometry; use pyo3::{ prelude::*, types::{PyDict, PyList}, }; -use serde::de::DeserializeOwned; -use stac::Format; +use stac::{Bbox, Format}; use stac_api::{BlockingClient, Fields, Item, ItemCollection, Items, Search}; +use stac_api::{Filter, Sortby}; use stac_duckdb::Client; -use std::str::FromStr; use tokio::runtime::Builder; #[pyfunction] #[pyo3(signature = (href, *, intersects=None, ids=None, collections=None, max_items=None, limit=None, bbox=None, datetime=None, include=None, exclude=None, sortby=None, filter=None, query=None, use_duckdb=None))] #[allow(clippy::too_many_arguments)] -pub fn search( - py: Python<'_>, +pub fn search<'py>( + py: Python<'py>, href: String, intersects: Option, ids: Option, @@ -27,10 +27,11 @@ pub fn search( exclude: Option, sortby: Option, filter: Option, - query: Option>, + query: Option>, use_duckdb: Option, -) -> PyResult> { +) -> PyResult> { let items = search_items( + py, href, intersects, ids, @@ -54,7 +55,8 @@ pub fn search( #[pyfunction] #[pyo3(signature = (outfile, href, *, intersects=None, ids=None, collections=None, max_items=None, limit=None, bbox=None, datetime=None, include=None, exclude=None, sortby=None, filter=None, query=None, format=None, options=None, use_duckdb=None))] #[allow(clippy::too_many_arguments)] -pub fn search_to( +pub fn search_to<'py>( + py: Python<'py>, outfile: String, href: String, intersects: Option, @@ -68,12 +70,13 @@ pub fn search_to( exclude: Option, sortby: Option, filter: Option, - query: Option>, + query: Option>, format: Option, options: Option>, use_duckdb: Option, ) -> PyResult { let items = search_items( + py, href, intersects, ids, @@ -109,7 +112,8 @@ pub fn search_to( } #[allow(clippy::too_many_arguments)] -fn search_items( +fn search_items<'py>( + py: Python<'py>, href: String, intersects: Option, ids: Option, @@ -122,44 +126,23 @@ fn search_items( exclude: Option, sortby: Option, filter: Option, - query: Option>, + query: Option>, use_duckdb: Option, ) -> PyResult> { - let mut fields = Fields::default(); - if let Some(include) = include { - fields.include = include.into(); - } - if let Some(exclude) = exclude { - fields.exclude = exclude.into(); - } - let fields = if fields.include.is_empty() && fields.exclude.is_empty() { - None - } else { - Some(fields) - }; - let query = Python::with_gil(|py| { - query - .map(|q| pythonize::depythonize(&q.into_bound(py))) - .transpose() - })?; - let mut search = Search { - intersects: intersects.map(|i| i.into()).transpose()?, - ids: ids.map(|ids| ids.into()), - collections: collections.map(|c| c.into()), - items: Items { - limit, - bbox: bbox - .map(|b| b.try_into()) - .transpose() - .map_err(Error::from)?, - datetime, - fields, - sortby: sortby.map(|s| s.into().into_iter().map(|s| s.parse().unwrap()).collect()), - filter: filter.map(|f| f.into()).transpose()?, - query, - ..Default::default() - }, - }; + let mut search = build_search( + py, + intersects, + ids, + collections, + limit, + bbox, + datetime, + include, + exclude, + sortby, + filter, + query, + )?; if use_duckdb .unwrap_or_else(|| matches!(Format::infer_from_href(&href), Some(Format::Geoparquet(_)))) { @@ -190,6 +173,87 @@ fn search_items( } } +/// Builds a [Search] from Python arguments. +pub fn build_search<'py>( + py: Python<'py>, + intersects: Option, + ids: Option, + collections: Option, + limit: Option, + bbox: Option>, + datetime: Option, + include: Option, + exclude: Option, + sortby: Option, + filter: Option, + query: Option>, +) -> PyResult { + let mut fields = Fields::default(); + if let Some(include) = include { + fields.include = include.into(); + } + if let Some(exclude) = exclude { + fields.exclude = exclude.into(); + } + let fields = if fields.include.is_empty() && fields.exclude.is_empty() { + None + } else { + Some(fields) + }; + let query = query + .map(|query| pythonize::depythonize(&query)) + .transpose()?; + let bbox = bbox + .map(|bbox| Bbox::try_from(bbox)) + .transpose() + .map_err(Error::from)?; + let sortby = sortby.map(|sortby| { + Vec::::from(sortby) + .into_iter() + .map(|s| s.parse::().unwrap()) // the parse is infallible + .collect::>() + }); + let filter = filter + .map(|filter| match filter { + StringOrDict::Dict(cql_json) => { + pythonize::depythonize(&cql_json.bind_borrowed(py)).map(Filter::Cql2Json) + } + StringOrDict::String(cql2_text) => Ok(Filter::Cql2Text(cql2_text)), + }) + .transpose()?; + let filter = filter + .map(|filter| filter.into_cql2_json()) + .transpose() + .map_err(Error::from)?; + let items = Items { + limit, + bbox, + datetime, + query, + fields, + sortby, + filter, + ..Default::default() + }; + + let intersects = intersects + .map(|intersects| match intersects { + StringOrDict::Dict(json) => pythonize::depythonize(&json.bind_borrowed(py)) + .map_err(Error::from) + .and_then(|json| Geometry::from_json_object(json).map_err(Error::from)), + StringOrDict::String(s) => s.parse().map_err(Error::from), + }) + .transpose()?; + let ids = ids.map(|ids| ids.into()); + let collections = collections.map(|ids| ids.into()); + Ok(Search { + items, + intersects, + ids, + collections, + }) +} + #[derive(FromPyObject)] pub enum StringOrDict { String(String), @@ -202,25 +266,11 @@ pub enum StringOrList { List(Vec), } -impl StringOrDict { - fn into(self) -> PyResult - where - Error: From<::Err>, - { - match self { - Self::String(s) => s.parse().map_err(Error::from).map_err(PyErr::from), - Self::Dict(dict) => { - Python::with_gil(|py| pythonize::depythonize(dict.bind(py))).map_err(PyErr::from) - } - } - } -} - -impl StringOrList { - fn into(self) -> Vec { - match self { - Self::List(list) => list, - Self::String(s) => vec![s], +impl From for Vec { + fn from(value: StringOrList) -> Vec { + match value { + StringOrList::List(list) => list, + StringOrList::String(s) => vec![s], } } } diff --git a/src/write.rs b/src/write.rs index 1993466..a87bce0 100644 --- a/src/write.rs +++ b/src/write.rs @@ -40,7 +40,7 @@ pub fn write<'py>( }) .map_err(Error::from)?; if let Some(put_result) = put_result { - let dict = PyDict::new_bound(py); + let dict = PyDict::new(py); if let Some(e_tag) = put_result.e_tag { dict.set_item("e_tag", e_tag)?; }