Repository: stac-utils/stac-rs (language: python)

Issue:
Add search to Python API
Including a function to download directly to a file, especially a parquet file

cc @wildintellect


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/stacrs.yml b/.github/workflows/stacrs.yml
index b07bdb5d..59eab6c6 100644
--- a/.github/workflows/stacrs.yml
+++ b/.github/workflows/stacrs.yml
@@ -4,6 +4,9 @@ on:
   push:
     tags:
       - 'python-*'
+  pull_request:
+    paths:
+      - python/**
   workflow_dispatch:
 
 permissions:
@@ -25,11 +28,11 @@ jobs:
       - name: Install dev requirements
         run: pip install -r python/requirements-dev.txt
       - name: Build
-        run: maturin build --manifest-path python/Cargo.toml --out dist
+        run: maturin build --manifest-path python/Cargo.toml --out dist -F geoparquet
       - name: Install stacrs
         run: pip install stacrs --find-links dist --no-index
       - name: Check
-        run: ruff check python && ruff format --check python && mypy python
+        run: ruff check python && ruff format --check python && mypy python --config-file=python/pyproject.toml
       - name: Test
         run: pytest python/tests
   linux:
@@ -60,7 +63,7 @@ jobs:
         uses: PyO3/maturin-action@v1
         with:
           target: ${{ matrix.platform.target }}
-          args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml
+          args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml -F geoparquet
           sccache: 'true'
       - name: Upload wheels
         uses: actions/upload-artifact@v4
@@ -120,7 +123,7 @@ jobs:
         uses: PyO3/maturin-action@v1
         with:
           target: ${{ matrix.platform.target }}
-          args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml
+          args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml -F geoparquet
           sccache: 'true'
       - name: Upload wheels
         uses: actions/upload-artifact@v4
@@ -148,7 +151,7 @@ jobs:
         uses: PyO3/maturin-action@v1
         with:
           target: ${{ matrix.platform.target }}
-          args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml
+          args: --release --out dist --find-interpreter --manifest-path python/Cargo.toml -F geoparquet
           sccache: 'true'
       - name: Upload wheels
         uses: actions/upload-artifact@v4
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index cc63c589..745b2999 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 ### Added
 
 - `Client` (from now-defunkt **stac-async**) ([#372](https://github.com/stac-utils/stac-rs/pull/372))
+- `BlockingClient` ([#387](https://github.com/stac-utils/stac-rs/pull/387))
 
 ## [0.5.0] - 2024-09-05
 
diff --git a/api/src/client.rs b/api/src/client.rs
index 29ae33f7..e2d5515f 100644
--- a/api/src/client.rs
+++ b/api/src/client.rs
@@ -1,3 +1,5 @@
+//! A STAC API client.
+
 use crate::{Error, GetItems, Item, ItemCollection, Items, Result, Search, UrlBuilder};
 use async_stream::try_stream;
 use futures::{pin_mut, Stream, StreamExt};
@@ -6,7 +8,9 @@ use reqwest::{header::HeaderMap, IntoUrl, Method, StatusCode};
 use serde::{de::DeserializeOwned, Serialize};
 use serde_json::{Map, Value};
 use stac::{Collection, Href, Link, Links};
+use std::pin::Pin;
 use tokio::{
+    runtime::{Builder, Runtime},
     sync::mpsc::{self, error::SendError},
     task::JoinHandle,
 };
@@ -21,6 +25,17 @@ pub struct Client {
     url_builder: UrlBuilder,
 }
 
+/// A client for interacting with STAC APIs without async.
+#[derive(Debug)]
+pub struct BlockingClient(Client);
+
+/// A blocking iterator over items.
+#[allow(missing_debug_implementations)]
+pub struct BlockingIterator {
+    runtime: Runtime,
+    stream: Pin<Box<dyn Stream<Item = Result<Item>>>>,
+}
+
 impl Client {
     /// Creates a new API client.
     ///
@@ -127,12 +142,12 @@ impl Client {
     ///
     /// let client = Client::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap();
     /// let mut search = Search { collections: Some(vec!["sentinel-2-l2a".to_string()]), ..Default::default() };
-    /// search.items.limit = Some(1);
     /// # tokio_test::block_on(async {
     /// let items: Vec<_> = client
     ///     .search(search)
     ///     .await
     ///     .unwrap()
+    ///     .take(1)
     ///     .map(|result| result.unwrap())
     ///     .collect()
     ///     .await;
@@ -226,6 +241,57 @@ impl Client {
     }
 }
 
+impl BlockingClient {
+    /// Creates a new blocking client.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac_api::BlockingClient;
+    ///
+    /// let client = BlockingClient::new("https://planetarycomputer.microsoft.com/api/stac/vi").unwrap();
+    /// ```
+    pub fn new(url: &str) -> Result<BlockingClient> {
+        Client::new(url).map(Self)
+    }
+
+    /// Searches an API, returning an iterable of items.
+    ///
+    /// To prevent fetching _all_ the items (which might be a lot), it is recommended to pass a `max_items`.
+    ///
+    /// # Examples
+    ///
+    /// ```no_run
+    /// use stac_api::{Search, BlockingClient};
+    ///
+    /// let client = BlockingClient::new("https://planetarycomputer.microsoft.com/api/stac/v1").unwrap();
+    /// let mut search = Search { collections: Some(vec!["sentinel-2-l2a".to_string()]), ..Default::default() };
+    /// let items: Vec<_> = client
+    ///     .search(search)
+    ///     .unwrap()
+    ///     .map(|result| result.unwrap())
+    ///     .take(1)
+    ///     .collect();
+    /// assert_eq!(items.len(), 1);
+    /// ```
+    pub fn search(&self, search: Search) -> Result<BlockingIterator> {
+        let runtime = Builder::new_current_thread().enable_all().build()?;
+        let stream = runtime.block_on(async move { self.0.search(search).await })?;
+        Ok(BlockingIterator {
+            runtime,
+            stream: Box::pin(stream),
+        })
+    }
+}
+
+impl Iterator for BlockingIterator {
+    type Item = Result<Item>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        self.runtime.block_on(self.stream.next())
+    }
+}
+
 fn stream_items(
     client: Client,
     page: ItemCollection,
diff --git a/api/src/error.rs b/api/src/error.rs
index 0eff3bf6..678535ff 100644
--- a/api/src/error.rs
+++ b/api/src/error.rs
@@ -52,6 +52,11 @@ pub enum Error {
     #[cfg(feature = "client")]
     InvalidMethod(#[from] http::method::InvalidMethod),
 
+    /// [std::io::Error]
+    #[error(transparent)]
+    #[cfg(feature = "client")]
+    Io(#[from] std::io::Error),
+
     /// [tokio::task::JoinError]
     #[error(transparent)]
     #[cfg(feature = "client")]
diff --git a/api/src/filter.rs b/api/src/filter.rs
index 306ed184..44e44e8b 100644
--- a/api/src/filter.rs
+++ b/api/src/filter.rs
@@ -1,3 +1,5 @@
+use std::{convert::Infallible, str::FromStr};
+
 use serde::{Deserialize, Serialize};
 use serde_json::{Map, Value};
 
@@ -20,6 +22,13 @@ impl Default for Filter {
     }
 }
 
+impl FromStr for Filter {
+    type Err = Infallible;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(Filter::Cql2Text(s.to_string()))
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::Filter;
diff --git a/api/src/lib.rs b/api/src/lib.rs
index 4f8b17ae..c773da62 100644
--- a/api/src/lib.rs
+++ b/api/src/lib.rs
@@ -64,7 +64,7 @@
 )]
 
 #[cfg(feature = "client")]
-mod client;
+pub mod client;
 mod collections;
 mod conformance;
 mod error;
@@ -78,7 +78,7 @@ mod sort;
 mod url_builder;
 
 #[cfg(feature = "client")]
-pub use client::Client;
+pub use client::{BlockingClient, Client};
 pub use {
     collections::Collections,
     conformance::{
diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md
index 4811e6df..aa96aa94 100644
--- a/python/CHANGELOG.md
+++ b/python/CHANGELOG.md
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 ## [Unreleased]
 
 - `migrate_href` ([#334](https://github.com/stac-utils/stac-rs/pull/334))
+- `search` and `search_to` ([#387](https://github.com/stac-utils/stac-rs/pull/387))
 
 ## [0.0.3] - 2024-08-29
 
diff --git a/python/CODE_OF_CONDUCT b/python/CODE_OF_CONDUCT
new file mode 120000
index 00000000..4cba9496
--- /dev/null
+++ b/python/CODE_OF_CONDUCT
@@ -0,0 +1,1 @@
+../CODE_OF_CONDUCT
\ No newline at end of file
diff --git a/python/Cargo.toml b/python/Cargo.toml
index 32b5f5a2..ea00547f 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -12,8 +12,16 @@ publish = false
 name = "stacrs"
 crate-type = ["cdylib"]
 
+[features]
+geoparquet = ["stac/geoparquet-compression"]
+
 [dependencies]
+geojson = "0.24"
 pyo3 = "0.22"
 pythonize = "0.22"
-stac = { path = "../core", features = ["reqwest"] }
+serde = "1"
+serde_json = "1"
+stac = { path = "../core", features = ["reqwest", "object-store-full"] }
+stac-api = { path = "../api", features = ["client"] }
 stac-validate = { path = "../validate" }
+tokio = { version = "1", features = ["rt"] }
diff --git a/python/README.md b/python/README.md
index 53d58343..58bc6655 100644
--- a/python/README.md
+++ b/python/README.md
@@ -21,6 +21,16 @@ Then:
 ```python
 import stacrs
 
+# Searches a STAC API
+items = stacrs.search(
+    "https://landsatlook.usgs.gov/stac-server",
+    collections="landsat-c2l2-sr",
+    intersects={"type": "Point", "coordinates": [-105.119, 40.173]},
+    sortby="-properties.datetime",
+    max_items=1,
+)
+
+# Validates a href using json-schema
 stacrs.validate_href("https://raw.githubusercontent.com/radiantearth/stac-spec/v1.0.0/examples/simple-item.json")
 ```
 
diff --git a/python/docs/CODE_OF_CONDUCT b/python/docs/CODE_OF_CONDUCT
new file mode 120000
index 00000000..4cba9496
--- /dev/null
+++ b/python/docs/CODE_OF_CONDUCT
@@ -0,0 +1,1 @@
+../CODE_OF_CONDUCT
\ No newline at end of file
diff --git a/python/docs/api.md b/python/docs/api.md
index 80282447..643b2ab5 100644
--- a/python/docs/api.md
+++ b/python/docs/api.md
@@ -2,12 +2,9 @@
 
 API documentation for **stacrs**.
 
-## Migrate
-
 ::: stacrs.migrate
 ::: stacrs.migrate_href
-
-## Validate
-
+::: stacrs.search
+::: stacrs.search_to
 ::: stacrs.validate
 ::: stacrs.validate_href
diff --git a/python/docs/index.md b/python/docs/index.md
deleted file mode 100644
index 782b124f..00000000
--- a/python/docs/index.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# stacrs
-
-A small, no-dependency Python library and command-line interface (CLI) for working with [STAC](https://stacspec.org), using [Rust](https://github.com/stac-utils/stac-rs) under the hood.
-
-## Installation
-
-```shell
-pip install stacrs
-```
-
-## Usage
-
-### API
-
-```python
-import stacrs
-
-stacrs.validate_href("https://raw.githubusercontent.com/radiantearth/stac-spec/master/examples/simple-item.json")
-```
-
-See [the API documentation](./api.md) for more.
-
-### CLI
-
-```shell
-$ stacrs --help
-Command line interface for stac-rs
-
-Usage: stacrs [OPTIONS] <COMMAND>
-
-Commands:
-  item       Creates a STAC Item
-  migrate    Migrates a STAC value from one version to another
-  search     Searches a STAC API
-  serve      Serves a STAC API
-  sort       Sorts the fields of STAC object
-  translate  Translates STAC values between formats
-  validate   Validates a STAC object or API endpoint using json-schema validation
-  help       Print this message or the help of the given subcommand(s)
-
-Options:
-  -c, --compact
-          Use a compact representation of the output, if possible
-
-  -i, --input-format <INPUT_FORMAT>
-          The input format. If not provided, the format will be detected from the input file extension when possible
-
-          Possible values:
-          - parquet: stac-geoparquet
-          - json:    JSON (the default)
-
-  -o, --output-format <OUTPUT_FORMAT>
-          The output format. If not provided, the format will be detected from the output file extension when possible
-
-          Possible values:
-          - parquet: stac-geoparquet
-          - json:    JSON (the default)
-
-  -h, --help
-          Print help (see a summary with '-h')
-
-  -V, --version
-          Print version
-```
diff --git a/python/docs/index.md b/python/docs/index.md
new file mode 120000
index 00000000..32d46ee8
--- /dev/null
+++ b/python/docs/index.md
@@ -0,0 +1,1 @@
+../README.md
\ No newline at end of file
diff --git a/python/pyproject.toml b/python/pyproject.toml
index eff7893e..70d472bc 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -29,3 +29,7 @@ Issues = "https://github.com/stac-utils/stac-rs/issues"
 
 [tool.maturin]
 features = ["pyo3/extension-module"]
+
+[[tool.mypy.overrides]]
+module = "pyarrow.*"
+ignore_missing_imports = true
diff --git a/python/requirements-dev.in b/python/requirements-dev.in
index 0b9db34c..02b2917a 100644
--- a/python/requirements-dev.in
+++ b/python/requirements-dev.in
@@ -2,3 +2,4 @@ maturin
 mypy
 ruff
 pytest
+stac-geoparquet
diff --git a/python/requirements-dev.txt b/python/requirements-dev.txt
index 9236ce53..26036d87 100644
--- a/python/requirements-dev.txt
+++ b/python/requirements-dev.txt
@@ -1,5 +1,15 @@
 # This file was autogenerated by uv via the following command:
 #    uv pip compile python/requirements-dev.in
+certifi==2024.8.30
+    # via
+    #   pyogrio
+    #   pyproj
+ciso8601==2.3.1
+    # via stac-geoparquet
+deltalake==0.19.2
+    # via stac-geoparquet
+geopandas==1.0.1
+    # via stac-geoparquet
 iniconfig==2.0.0
     # via pytest
 maturin==1.7.1
@@ -8,13 +18,58 @@ mypy==1.11.2
     # via -r python/requirements-dev.in
 mypy-extensions==1.0.0
     # via mypy
+numpy==2.1.1
+    # via
+    #   geopandas
+    #   pandas
+    #   pyarrow
+    #   pyogrio
+    #   shapely
+orjson==3.10.7
+    # via stac-geoparquet
 packaging==24.1
-    # via pytest
+    # via
+    #   geopandas
+    #   pyogrio
+    #   pytest
+    #   stac-geoparquet
+pandas==2.2.2
+    # via
+    #   geopandas
+    #   stac-geoparquet
 pluggy==1.5.0
     # via pytest
-pytest==8.3.2
+pyarrow==17.0.0
+    # via
+    #   deltalake
+    #   stac-geoparquet
+pyogrio==0.9.0
+    # via geopandas
+pyproj==3.6.1
+    # via
+    #   geopandas
+    #   stac-geoparquet
+pystac==1.10.1
+    # via stac-geoparquet
+pytest==8.3.3
+    # via -r python/requirements-dev.in
+python-dateutil==2.9.0.post0
+    # via
+    #   pandas
+    #   pystac
+pytz==2024.2
+    # via pandas
+ruff==0.6.5
     # via -r python/requirements-dev.in
-ruff==0.6.2
+shapely==2.0.6
+    # via
+    #   geopandas
+    #   stac-geoparquet
+six==1.16.0
+    # via python-dateutil
+stac-geoparquet==0.6.0
     # via -r python/requirements-dev.in
 typing-extensions==4.12.2
     # via mypy
+tzdata==2024.1
+    # via pandas
diff --git a/python/src/error.rs b/python/src/error.rs
new file mode 100644
index 00000000..44e9118f
--- /dev/null
+++ b/python/src/error.rs
@@ -0,0 +1,47 @@
+use std::convert::Infallible;
+
+use pyo3::{exceptions::PyException, PyErr};
+
+pub struct Error(pub String);
+
+impl From<stac::Error> for Error {
+    fn from(value: stac::Error) -> Self {
+        Error(value.to_string())
+    }
+}
+
+impl From<stac_api::Error> for Error {
+    fn from(value: stac_api::Error) -> Self {
+        Error(value.to_string())
+    }
+}
+
+impl From<stac_validate::Error> for Error {
+    fn from(value: stac_validate::Error) -> Self {
+        Error(value.to_string())
+    }
+}
+
+impl From<geojson::Error> for Error {
+    fn from(value: geojson::Error) -> Self {
+        Error(value.to_string())
+    }
+}
+
+impl From<serde_json::Error> for Error {
+    fn from(value: serde_json::Error) -> Self {
+        Error(value.to_string())
+    }
+}
+
+impl From<Infallible> for Error {
+    fn from(_: Infallible) -> Self {
+        unreachable!()
+    }
+}
+
+impl From<Error> for PyErr {
+    fn from(value: Error) -> Self {
+        PyException::new_err(value.0)
+    }
+}
diff --git a/python/src/lib.rs b/python/src/lib.rs
index 8aa06084..28fca6df 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -1,151 +1,23 @@
 #![deny(unused_crate_dependencies)]
 
-use pyo3::{create_exception, exceptions::PyException, prelude::*, types::PyDict};
-use stac::{Migrate, Value};
-use stac_validate::Validate;
+mod error;
+mod migrate;
+mod search;
+mod validate;
 
-create_exception!(stacrs, StacrsError, PyException, "An error in stacrs");
+use error::Error;
+use pyo3::prelude::*;
 
-/// Migrates a STAC dictionary to another version.
-///
-/// Migration can be as simple as updating the `stac_version` attribute, but
-/// sometimes can be more complicated. For example, when migrating to v1.1.0,
-/// [eo:bands and raster:bands should be consolidated to the new bands
-/// structure](https://github.com/radiantearth/stac-spec/releases/tag/v1.1.0-beta.1).
-///
-/// See [the stac-rs
-/// documentation](https://docs.rs/stac/latest/stac/enum.Version.html) for
-/// supported versions.
-///
-/// Args:
-///     value (dict[str, Any]): The STAC value to migrate
-///     version (str | None): The version to migrate to. If not provided, the
-///         value will be migrated to the latest stable version.
-///
-/// Returns:
-///     dict[str, Any]: The migrated dictionary
-///
-/// Examples:
-///     >>> with open("examples/simple-item.json") as f:
-///     >>>     item = json.load(f)
-///     >>> item = stacrs.migrate(item, "1.1.0-beta.1")
-///     >>> assert item["stac_version"] == "1.1.0-beta.1"
-#[pyfunction]
-#[pyo3(signature = (value, version=None))]
-fn migrate<'py>(value: &Bound<'py, PyDict>, version: Option<&str>) -> PyResult<Bound<'py, PyDict>> {
-    let py = value.py();
-    let value: Value = pythonize::depythonize(value)?;
-    let version = version
-        .map(|version| version.parse().unwrap())
-        .unwrap_or_default();
-    let value = value
-        .migrate(&version)
-        .map_err(|err| StacrsError::new_err(err.to_string()))?;
-    let value = pythonize::pythonize(py, &value)?;
-    value.downcast_into().map_err(PyErr::from)
-}
-
-/// Migrates a STAC dictionary at the given href to another version.
-///
-/// Migration can be as simple as updating the `stac_version` attribute, but
-/// sometimes can be more complicated. For example, when migrating to v1.1.0,
-/// [eo:bands and raster:bands should be consolidated to the new bands
-/// structure](https://github.com/radiantearth/stac-spec/releases/tag/v1.1.0-beta.1).
-///
-/// See [the stac-rs
-/// documentation](https://docs.rs/stac/latest/stac/enum.Version.html) for
-/// supported versions.
-///
-/// Args:
-///     href (str): The href to read the STAC object from
-///     version (str | None): The version to migrate to. If not provided, the
-///         value will be migrated to the latest stable version.
-///
-/// Examples:
-///     >>> item = stacrs.migrate_href("examples/simple-item.json", "1.1.0-beta.1")
-///     >>> assert item["stac_version"] == "1.1.0-beta.1"
-#[pyfunction]
-#[pyo3(signature = (href, version=None))]
-fn migrate_href<'py>(
-    py: Python<'py>,
-    href: &str,
-    version: Option<&str>,
-) -> PyResult<Bound<'py, PyDict>> {
-    let value: Value = stac::read(href).map_err(|err| StacrsError::new_err(err.to_string()))?;
-    let version = version
-        .map(|version| version.parse().unwrap())
-        .unwrap_or_default();
-    let value = value
-        .migrate(&version)
-        .map_err(|err| StacrsError::new_err(err.to_string()))?;
-    let value = pythonize::pythonize(py, &value)?;
-    value.downcast_into().map_err(PyErr::from)
-}
-
-/// Validates a single href with json-schema.
-///
-/// Args:
-///     href (str): The href of the STAC value to validate
-///
-/// Raises:
-///     StacrsError: On any input/output error, or on a validation error
-///
-/// Examples:
-///     >>> stacrs.validate_href("examples/simple-item.json")
-///     >>> stacrs.validate_href("data/invalid-item.json")
-///     Traceback (most recent call last):
-///     File "<stdin>", line 1, in <module>
-///     stacrs.StacrsError: Validation errors: "collection" is a required property
-#[pyfunction]
-fn validate_href(href: &str) -> PyResult<()> {
-    let value: Value = stac::read(href).map_err(|err| StacrsError::new_err(err.to_string()))?;
-    validate_value(value)
-}
-
-/// Validates a STAC dictionary with json-schema.
-///
-/// Args:
-///     value (dict[str, Any]): The STAC value to validate
-///
-/// Raises:
-///     StacrsError: On a validation error
-///
-/// Examples:
-///     >>> with open("examples/simple-item.json") as f:
-///     >>>     data = json.load(f)
-///     >>> stacrs.validate(data)
-#[pyfunction]
-fn validate(value: &Bound<'_, PyDict>) -> PyResult<()> {
-    let value: Value = pythonize::depythonize(value)?;
-    validate_value(value)
-}
-
-fn validate_value(value: Value) -> PyResult<()> {
-    if let Err(error) = value.validate() {
-        match error {
-            stac_validate::Error::Validation(errors) => {
-                let mut message = "Validation errors: ".to_string();
-                for error in errors {
-                    message.push_str(&format!("{}, ", error));
-                }
-                message.pop();
-                message.pop();
-                Err(StacrsError::new_err(message))
-            }
-            _ => Err(StacrsError::new_err(error.to_string())),
-        }
-    } else {
-        Ok(())
-    }
-}
+type Result<T> = std::result::Result<T, Error>;
 
 /// A collection of functions for working with STAC, using Rust under the hood.
 #[pymodule]
 fn stacrs(m: &Bound<'_, PyModule>) -> PyResult<()> {
-    m.add_function(wrap_pyfunction!(migrate, m)?)?;
-    m.add_function(wrap_pyfunction!(migrate_href, m)?)?;
-    m.add_function(wrap_pyfunction!(validate_href, m)?)?;
-    m.add_function(wrap_pyfunction!(validate, m)?)?;
-    m.add("StacrsError", m.py().get_type_bound::<StacrsError>())?;
+    m.add_function(wrap_pyfunction!(migrate::migrate, m)?)?;
+    m.add_function(wrap_pyfunction!(migrate::migrate_href, m)?)?;
+    m.add_function(wrap_pyfunction!(search::search, m)?)?;
+    m.add_function(wrap_pyfunction!(search::search_to, m)?)?;
+    m.add_function(wrap_pyfunction!(validate::validate, m)?)?;
+    m.add_function(wrap_pyfunction!(validate::validate_href, m)?)?;
     Ok(())
 }
diff --git a/python/src/migrate.rs b/python/src/migrate.rs
new file mode 100644
index 00000000..7d5471fa
--- /dev/null
+++ b/python/src/migrate.rs
@@ -0,0 +1,82 @@
+use crate::Error;
+use pyo3::{prelude::*, types::PyDict};
+use stac::{Migrate, Value};
+
+/// Migrates a STAC dictionary to another version.
+///
+/// Migration can be as simple as updating the `stac_version` attribute, but
+/// sometimes can be more complicated. For example, when migrating to v1.1.0,
+/// [eo:bands and raster:bands should be consolidated to the new bands
+/// structure](https://github.com/radiantearth/stac-spec/releases/tag/v1.1.0-beta.1).
+///
+/// See [the stac-rs
+/// documentation](https://docs.rs/stac/latest/stac/enum.Version.html) for
+/// supported versions.
+///
+/// Args:
+///     value (dict[str, Any]): The STAC value to migrate
+///     version (str | None): The version to migrate to. If not provided, the
+///         value will be migrated to the latest stable version.
+///
+/// Returns:
+///     dict[str, Any]: The migrated dictionary
+///
+/// Examples:
+///     >>> with open("examples/simple-item.json") as f:
+///     >>>     item = json.load(f)
+///     >>> item = stacrs.migrate(item, "1.1.0-beta.1")
+///     >>> assert item["stac_version"] == "1.1.0-beta.1"
+#[pyfunction]
+#[pyo3(signature = (value, version=None))]
+pub fn migrate<'py>(
+    value: &Bound<'py, PyDict>,
+    version: Option<&str>,
+) -> PyResult<Bound<'py, PyDict>> {
+    let py = value.py();
+    let value: Value = pythonize::depythonize(value)?;
+    let version = version
+        .map(|version| version.parse())
+        .transpose()
+        .map_err(Error::from)?
+        .unwrap_or_default();
+    let value = value.migrate(&version).map_err(Error::from)?;
+    let value = pythonize::pythonize(py, &value)?;
+    value.downcast_into().map_err(PyErr::from)
+}
+
+/// Migrates a STAC dictionary at the given href to another version.
+///
+/// Migration can be as simple as updating the `stac_version` attribute, but
+/// sometimes can be more complicated. For example, when migrating to v1.1.0,
+/// [eo:bands and raster:bands should be consolidated to the new bands
+/// structure](https://github.com/radiantearth/stac-spec/releases/tag/v1.1.0-beta.1).
+///
+/// See [the stac-rs
+/// documentation](https://docs.rs/stac/latest/stac/enum.Version.html) for
+/// supported versions.
+///
+/// Args:
+///     href (str): The href to read the STAC object from
+///     version (str | None): The version to migrate to. If not provided, the
+///         value will be migrated to the latest stable version.
+///
+/// Examples:
+///     >>> item = stacrs.migrate_href("examples/simple-item.json", "1.1.0-beta.1")
+///     >>> assert item["stac_version"] == "1.1.0-beta.1"
+#[pyfunction]
+#[pyo3(signature = (href, version=None))]
+pub fn migrate_href<'py>(
+    py: Python<'py>,
+    href: &str,
+    version: Option<&str>,
+) -> PyResult<Bound<'py, PyDict>> {
+    let value: Value = stac::read(href).map_err(Error::from)?;
+    let version = version
+        .map(|version| version.parse())
+        .transpose()
+        .map_err(Error::from)?
+        .unwrap_or_default();
+    let value = value.migrate(&version).map_err(Error::from)?;
+    let value = pythonize::pythonize(py, &value)?;
+    value.downcast_into().map_err(PyErr::from)
+}
diff --git a/python/src/search.rs b/python/src/search.rs
new file mode 100644
index 00000000..03a1c456
--- /dev/null
+++ b/python/src/search.rs
@@ -0,0 +1,294 @@
+use crate::Error;
+use pyo3::{
+    prelude::*,
+    types::{PyDict, PyList},
+};
+use serde::de::DeserializeOwned;
+use stac::io::Format;
+use stac_api::{BlockingClient, Fields, Item, ItemCollection, Items, Search};
+use std::str::FromStr;
+use tokio::runtime::Builder;
+
+/// Searches a STAC API server.
+///
+/// Args:
+///     href (str): The STAC API to search.
+///     intersects (str | dict[str, Any] | GeoInterface | None): Searches items
+///         by performing intersection between their geometry and provided GeoJSON
+///         geometry.
+///     ids (list[str] | None): Array of Item ids to return.
+///     collections (list[str] | None): Array of one or more Collection IDs that
+///         each matching Item must be in.
+///     max_items (int | None): The maximum number of items to iterate through.
+///     limit (int | None): The page size returned from the server. Use
+///         `max_items` to actually limit the number of items returned from this
+///         function.
+///     bbox (list[float] | None): Requested bounding box.
+///     datetime (str | None): Single date+time, or a range (‘/’ separator),
+///         formatted to RFC 3339, section 5.6.  Use double dots .. for open
+///         date ranges.
+///     include (list[str]] | None): fields to include in the response (see [the
+///         extension
+///         docs](https://github.com/stac-api-extensions/fields?tab=readme-ov-file#includeexclude-semantics))
+///         for more on the semantics).
+///     exclude (list[str]] | None): fields to exclude from the response (see [the
+///         extension
+///         docs](https://github.com/stac-api-extensions/fields?tab=readme-ov-file#includeexclude-semantics))
+///         for more on the semantics).
+///     sortby (list[str] | None): Fields by which to sort results (use `-field` to sort descending).
+///     filter (str | dict[str, Any] | none): CQL2 filter expression. Strings
+///         will be interpreted as cql2-text, dictionaries as cql2-json.
+///     query (dict[str, Any] | None): Additional filtering based on properties.
+///         It is recommended to use filter instead, if possible.
+///
+/// Returns:
+///     list[dict[str, Any]]: A list of the returned STAC items.
+///
+/// Examples:
+///     >>> items = stacrs.search(
+///     ...     "https://landsatlook.usgs.gov/stac-server",
+///     ...     collections=["landsat-c2l2-sr"],
+///     ...     intersects={"type": "Point", "coordinates": [-105.119, 40.173]},
+///     ...     sortby="-properties.datetime",
+///     ...     max_items=1,
+///     ... )
+#[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))]
+pub fn search<'py>(
+    py: Python<'py>,
+    href: String,
+    intersects: Option<StringOrDict>,
+    ids: Option<StringOrList>,
+    collections: Option<StringOrList>,
+    max_items: Option<usize>,
+    limit: Option<u64>,
+    bbox: Option<Vec<f64>>,
+    datetime: Option<String>,
+    include: Option<StringOrList>,
+    exclude: Option<StringOrList>,
+    sortby: Option<StringOrList>,
+    filter: Option<StringOrDict>,
+    query: Option<Py<PyDict>>,
+) -> PyResult<Bound<'py, PyList>> {
+    let items = search_items(
+        href,
+        intersects,
+        ids,
+        collections,
+        max_items,
+        limit,
+        bbox,
+        datetime,
+        include,
+        exclude,
+        sortby,
+        filter,
+        query,
+    )?;
+    pythonize::pythonize(py, &items)
+        .map_err(PyErr::from)
+        .and_then(|v| v.extract())
+}
+
+/// Searches a STAC API server and saves the result to an output file.
+///
+/// Args:
+///     outfile (str): The output href. This can be a local file path, or any
+///         url scheme supported by [stac::object_store::write].
+///     href (str): The STAC API to search.
+///     intersects (str | dict[str, Any] | GeoInterface | None): Searches items
+///         by performing intersection between their geometry and provided GeoJSON
+///         geometry.
+///     ids (list[str] | None): Array of Item ids to return.
+///     collections (list[str] | None): Array of one or more Collection IDs that
+///         each matching Item must be in.
+///     max_items (int | None): The maximum number of items to iterate through.
+///     limit (int | None): The page size returned from the server. Use
+///         `max_items` to actually limit the number of items returned from this
+///         function.
+///     bbox (list[float] | None): Requested bounding box.
+///     datetime (str | None): Single date+time, or a range (‘/’ separator),
+///         formatted to RFC 3339, section 5.6.  Use double dots .. for open
+///         date ranges.
+///     include (list[str]] | None): fields to include in the response (see [the
+///         extension
+///         docs](https://github.com/stac-api-extensions/fields?tab=readme-ov-file#includeexclude-semantics))
+///         for more on the semantics).
+///     exclude (list[str]] | None): fields to exclude from the response (see [the
+///         extension
+///         docs](https://github.com/stac-api-extensions/fields?tab=readme-ov-file#includeexclude-semantics))
+///         for more on the semantics).
+///     sortby (list[str] | None): Fields by which to sort results (use `-field` to sort descending).
+///     filter (str | dict[str, Any] | none): CQL2 filter expression. Strings
+///         will be interpreted as cql2-text, dictionaries as cql2-json.
+///     query (dict[str, Any] | None): Additional filtering based on properties.
+///         It is recommended to use filter instead, if possible.
+///     format (str | None): The output format. If none, will be inferred from
+///         the outfile extension, and if that fails will fall back to compact JSON.
+///     options (list[tuple[str, str]] | None): Configuration values to pass to the object store backend.
+///
+/// Returns:
+///     list[dict[str, Any]]: A list of the returned STAC items.
+///
+/// Examples:
+///     >>> items = stacrs.search_to("out.parquet",
+///     ...     "https://landsatlook.usgs.gov/stac-server",
+///     ...     collections=["landsat-c2l2-sr"],
+///     ...     intersects={"type": "Point", "coordinates": [-105.119, 40.173]},
+///     ...     sortby="-properties.datetime",
+///     ...     max_items=1,
+///     ... )
+#[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))]
+pub fn search_to(
+    outfile: String,
+    href: String,
+    intersects: Option<StringOrDict>,
+    ids: Option<StringOrList>,
+    collections: Option<StringOrList>,
+    max_items: Option<usize>,
+    limit: Option<u64>,
+    bbox: Option<Vec<f64>>,
+    datetime: Option<String>,
+    include: Option<StringOrList>,
+    exclude: Option<StringOrList>,
+    sortby: Option<StringOrList>,
+    filter: Option<StringOrDict>,
+    query: Option<Py<PyDict>>,
+    format: Option<String>,
+    options: Option<Vec<(String, String)>>,
+) -> PyResult<()> {
+    let items = search_items(
+        href,
+        intersects,
+        ids,
+        collections,
+        max_items,
+        limit,
+        bbox,
+        datetime,
+        include,
+        exclude,
+        sortby,
+        filter,
+        query,
+    )?;
+    let format = format
+        .map(|s| s.parse())
+        .transpose()
+        .map_err(Error::from)?
+        .or_else(|| Format::infer_from_href(&outfile))
+        .unwrap_or_default();
+    let item_collection = ItemCollection::from(items);
+    Builder::new_current_thread()
+        .build()?
+        .block_on(stac::io::put_format_opts(
+            outfile,
+            serde_json::to_value(item_collection).map_err(Error::from)?,
+            format,
+            options.unwrap_or_default(),
+        ))
+        .map_err(Error::from)?;
+    Ok(())
+}
+
+fn search_items(
+    href: String,
+    intersects: Option<StringOrDict>,
+    ids: Option<StringOrList>,
+    collections: Option<StringOrList>,
+    max_items: Option<usize>,
+    limit: Option<u64>,
+    bbox: Option<Vec<f64>>,
+    datetime: Option<String>,
+    include: Option<StringOrList>,
+    exclude: Option<StringOrList>,
+    sortby: Option<StringOrList>,
+    filter: Option<StringOrDict>,
+    query: Option<Py<PyDict>>,
+) -> PyResult<Vec<Item>> {
+    let client = BlockingClient::new(&href).map_err(Error::from)?;
+    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 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 items = client.search(search).map_err(Error::from)?;
+    if let Some(max_items) = max_items {
+        items
+            .take(max_items)
+            .collect::<Result<_, _>>()
+            .map_err(Error::from)
+            .map_err(PyErr::from)
+    } else {
+        items
+            .collect::<Result<_, _>>()
+            .map_err(Error::from)
+            .map_err(PyErr::from)
+    }
+}
+
+#[derive(FromPyObject)]
+pub enum StringOrDict {
+    String(String),
+    Dict(Py<PyDict>),
+}
+
+#[derive(FromPyObject)]
+pub enum StringOrList {
+    String(String),
+    List(Vec<String>),
+}
+
+impl StringOrDict {
+    fn into<T: FromStr + DeserializeOwned>(self) -> PyResult<T>
+    where
+        Error: From<<T as FromStr>::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<String> {
+        match self {
+            Self::List(list) => list,
+            Self::String(s) => vec![s],
+        }
+    }
+}
diff --git a/python/src/validate.rs b/python/src/validate.rs
new file mode 100644
index 00000000..2d16d384
--- /dev/null
+++ b/python/src/validate.rs
@@ -0,0 +1,61 @@
+use crate::{Error, Result};
+use pyo3::{prelude::*, types::PyDict};
+use stac::Value;
+use stac_validate::Validate;
+
+/// Validates a single href with json-schema.
+///
+/// Args:
+///     href (str): The href of the STAC value to validate
+///
+/// Raises:
+///     StacrsError: On any input/output error, or on a validation error
+///
+/// Examples:
+///     >>> stacrs.validate_href("examples/simple-item.json")
+///     >>> stacrs.validate_href("data/invalid-item.json")
+///     Traceback (most recent call last):
+///     File "<stdin>", line 1, in <module>
+///     stacrs.StacrsError: Validation errors: "collection" is a required property
+#[pyfunction]
+pub fn validate_href(href: &str) -> Result<()> {
+    let value: Value = stac::read(href)?;
+    validate_value(value)
+}
+
+/// Validates a STAC dictionary with json-schema.
+///
+/// Args:
+///     value (dict[str, Any]): The STAC value to validate
+///
+/// Raises:
+///     StacrsError: On a validation error
+///
+/// Examples:
+///     >>> with open("examples/simple-item.json") as f:
+///     >>>     data = json.load(f)
+///     >>> stacrs.validate(data)
+#[pyfunction]
+pub fn validate(value: &Bound<'_, PyDict>) -> PyResult<()> {
+    let value: Value = pythonize::depythonize(value)?;
+    validate_value(value).map_err(Error::into)
+}
+
+fn validate_value(value: Value) -> Result<()> {
+    if let Err(error) = value.validate() {
+        match error {
+            stac_validate::Error::Validation(errors) => {
+                let mut message = "Validation errors: ".to_string();
+                for error in errors {
+                    message.push_str(&format!("{}, ", error));
+                }
+                message.pop();
+                message.pop();
+                Err(Error(message))
+            }
+            _ => Err(error.into()),
+        }
+    } else {
+        Ok(())
+    }
+}
diff --git a/python/stacrs.pyi b/python/stacrs.pyi
index c45512fc..0c1cbcbd 100644
--- a/python/stacrs.pyi
+++ b/python/stacrs.pyi
@@ -1,9 +1,41 @@
-from typing import Any, Optional
+from typing import Any, Optional, Tuple
 
-class StacrsError(Exception): ...
-
-def main() -> int: ...
 def migrate_href(href: str, version: Optional[str] = None) -> dict[str, Any]: ...
 def migrate(value: dict[str, Any], version: Optional[str] = None) -> dict[str, Any]: ...
+def search(
+    href: str,
+    *,
+    intersects: Optional[str | dict[str, Any]] = None,
+    ids: Optional[str | list[str]] = None,
+    collections: Optional[str | list[str]] = None,
+    max_items: Optional[int] = None,
+    limit: Optional[int] = None,
+    bbox: Optional[list[float]] = None,
+    datetime: Optional[str] = None,
+    include: Optional[str | list[str]] = None,
+    exclude: Optional[str | list[str]] = None,
+    sortby: Optional[str | list[str]] = None,
+    filter: Optional[str | dict[str, Any]] = None,
+    query: Optional[dict[str, Any]] = None,
+) -> list[dict[str, Any]]: ...
+def search_to(
+    outfile: str,
+    href: str,
+    *,
+    intersects: Optional[str | dict[str, Any]] = None,
+    ids: Optional[str | list[str]] = None,
+    collections: Optional[str | list[str]] = None,
+    max_items: Optional[int] = None,
+    limit: Optional[int] = None,
+    bbox: Optional[list[float]] = None,
+    datetime: Optional[str] = None,
+    include: Optional[str | list[str]] = None,
+    exclude: Optional[str | list[str]] = None,
+    sortby: Optional[str | list[str]] = None,
+    filter: Optional[str | dict[str, Any]] = None,
+    query: Optional[dict[str, Any]] = None,
+    format: Optional[str] = None,
+    options: Optional[list[Tuple[str, str]]] = None,
+) -> list[dict[str, Any]]: ...
 def validate_href(href: str) -> None: ...
 def validate(value: dict[str, Any]) -> None: ...


Test patch:
diff --git a/python/tests/test_search.py b/python/tests/test_search.py
new file mode 100644
index 00000000..df3631e1
--- /dev/null
+++ b/python/tests/test_search.py
@@ -0,0 +1,45 @@
+import json
+from pathlib import Path
+
+import pyarrow.parquet
+import stac_geoparquet.arrow
+import stacrs
+
+
+def test_search() -> None:
+    items = stacrs.search(
+        "https://landsatlook.usgs.gov/stac-server",
+        collections="landsat-c2l2-sr",
+        intersects={"type": "Point", "coordinates": [-105.119, 40.173]},
+        sortby="-properties.datetime",
+        max_items=1,
+    )
+    assert len(items) == 1
+
+
+def test_search_to(tmp_path: Path) -> None:
+    stacrs.search_to(
+        str(tmp_path / "out.json"),
+        "https://landsatlook.usgs.gov/stac-server",
+        collections="landsat-c2l2-sr",
+        intersects={"type": "Point", "coordinates": [-105.119, 40.173]},
+        sortby="-properties.datetime",
+        max_items=1,
+    )
+    with open(tmp_path / "out.json") as f:
+        data = json.load(f)
+    assert len(data["features"]) == 1
+
+
+def test_search_to_geoparquet(tmp_path: Path) -> None:
+    stacrs.search_to(
+        str(tmp_path / "out.parquet"),
+        "https://landsatlook.usgs.gov/stac-server",
+        collections="landsat-c2l2-sr",
+        intersects={"type": "Point", "coordinates": [-105.119, 40.173]},
+        sortby="-properties.datetime",
+        max_items=1,
+    )
+    table = pyarrow.parquet.read_table(tmp_path / "out.parquet")
+    items = list(stac_geoparquet.arrow.stac_table_to_items(table))
+    assert len(items) == 1
diff --git a/python/tests/test_validate.py b/python/tests/test_validate.py
index 8f3ca6ad..4fb1e476 100644
--- a/python/tests/test_validate.py
+++ b/python/tests/test_validate.py
@@ -3,7 +3,6 @@
 
 import pytest
 import stacrs
-from stacrs import StacrsError
 
 
 def test_validate_href_ok(spec_examples: Path) -> None:
@@ -11,7 +10,7 @@ def test_validate_href_ok(spec_examples: Path) -> None:
 
 
 def test_validate_href_invalid(data: Path) -> None:
-    with pytest.raises(StacrsError):
+    with pytest.raises(Exception):
         stacrs.validate_href(str(data / "invalid-item.json"))