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

Issue:
Create Python bindings for stac-geoparquet
It would be nice to provide Python folks a way to do conversions quickly.


Discussion / hints:

Fix patch:
diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md
index aa96aa94..a60e86da 100644
--- a/python/CHANGELOG.md
+++ b/python/CHANGELOG.md
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 - `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))
+- `read`, `write`, and `pystac` ([#418](https://github.com/stac-utils/stac-rs/pull/418))
 
 ## [0.0.3] - 2024-08-29
 
diff --git a/python/Cargo.toml b/python/Cargo.toml
index 563e6766..32988d78 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -13,7 +13,9 @@ name = "stacrs"
 crate-type = ["cdylib"]
 
 [features]
-geoparquet = ["stac/geoparquet-compression"]
+geoparquet = [
+    "stac/geoparquet-compression",
+] # need to be able to disable geoparquet so this builds on readthedocs
 
 [dependencies]
 geojson = "0.24"
diff --git a/python/README.md b/python/README.md
index 58bc6655..fcf0db70 100644
--- a/python/README.md
+++ b/python/README.md
@@ -6,7 +6,7 @@
 ![PyPI - License](https://img.shields.io/pypi/l/stacrs?style=for-the-badge)
 [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg?style=for-the-badge)](./CODE_OF_CONDUCT)
 
-A Python package for working with [STAC](https://stacspec.org/), using Rust under the hood.
+A no-dependency Python package for [STAC](https://stacspec.org/), using Rust under the hood.
 
 ## Usage
 
@@ -21,21 +21,61 @@ Then:
 ```python
 import stacrs
 
-# Searches a STAC API
+# Search 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,
+    max_items=100,
 )
 
-# Validates a href using json-schema
-stacrs.validate_href("https://raw.githubusercontent.com/radiantearth/stac-spec/v1.0.0/examples/simple-item.json")
+# Write items to a stac-geoparquet file
+stacrs.write("items.parquet", items)
+
+# Read items from a stac-geoparquet file as an item collection
+item_collection = stacrs.read("items.parquet")
+
+# Use `search_to` for better performance if you know you'll be writing the items
+# to a file
+stacrs.search_to(
+    "items.parquet",
+    "https://landsatlook.usgs.gov/stac-server",
+    collections="landsat-c2l2-sr",
+    intersects={"type": "Point", "coordinates": [-105.119, 40.173]},
+    sortby="-properties.datetime",
+    max_items=100,
+)
+```
+
+### pystac
+
+If [pystac](https://pystac.readthedocs.io) is present, `stacrs.pystac` provides functions that take **pystac** objects as their inputs and outputs:
+
+```python
+import pystac
+import stacrs.pystac
+
+item = pystac.read_file("item.json")
+stacrs.pystac.validate(item)
+
+items = list(stacrs.pystac.search(...))
+```
+
+You can install **pystac** with **stacrs** via an optional dependency:
+
+```shell
+pip install 'stacrs[pystac]'
 ```
 
-See [the documentation](https://stacrs.readthedocs.io/) for more information.
+## Comparisons
+
+This package (intentionally) has limited functionality, as it is _not_ intended to be a replacement for existing Python STAC packages.
+[pystac](https://pystac.readthedocs.io) is a mature Python library with a significantly richer API for working with STAC objects.
+For querying STAC APIs, [pystac-client](https://pystac-client.readthedocs.io) is more feature-rich than our simplistic `stacrs.search`.
+
+That being said, it is hoped that **stacrs** will be a nice complement to the existing Python STAC ecosystem by providing a no-dependency package with unique capabilities, such as searching directly into a stac-geoparquet file.
 
 ## Other info
 
-This crate is part of the [stac-rs](https://github.com/stac-utils/stac-rs) monorepo, see its README for contributing and license information.
+This package is part of the [stac-rs](https://github.com/stac-utils/stac-rs) monorepo, see its README for contributing and license information.
diff --git a/python/docs/api.md b/python/docs/api.md
deleted file mode 100644
index 643b2ab5..00000000
--- a/python/docs/api.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# API
-
-API documentation for **stacrs**.
-
-::: stacrs.migrate
-::: stacrs.migrate_href
-::: stacrs.search
-::: stacrs.search_to
-::: stacrs.validate
-::: stacrs.validate_href
diff --git a/python/docs/api/index.md b/python/docs/api/index.md
new file mode 100644
index 00000000..3be31050
--- /dev/null
+++ b/python/docs/api/index.md
@@ -0,0 +1,9 @@
+# API
+
+API documentation for **stacrs**.
+
+- [migrate](./migrate.md)
+- [search](./search.md)
+- [validate](./validate.md)
+- [write](./write.md)
+- [pystac](./pystac.md)
diff --git a/python/docs/api/migrate.md b/python/docs/api/migrate.md
new file mode 100644
index 00000000..5c350b35
--- /dev/null
+++ b/python/docs/api/migrate.md
@@ -0,0 +1,4 @@
+# Migration
+
+::: stacrs.migrate
+::: stacrs.migrate_href
diff --git a/python/docs/api/pystac.md b/python/docs/api/pystac.md
new file mode 100644
index 00000000..691f3503
--- /dev/null
+++ b/python/docs/api/pystac.md
@@ -0,0 +1,14 @@
+# pystac
+
+These functions are only available if [pystac](https://pystac.readthedocs.io/) is available on your system.
+To install **pystac** and **stacrs**:
+
+```shell
+pip install 'stacrs[pystac]'
+```
+
+::: stacrs.pystac.migrate
+::: stacrs.pystac.read
+::: stacrs.pystac.search
+::: stacrs.pystac.validate
+::: stacrs.pystac.write
diff --git a/python/docs/api/read.md b/python/docs/api/read.md
new file mode 100644
index 00000000..5ac76ee5
--- /dev/null
+++ b/python/docs/api/read.md
@@ -0,0 +1,3 @@
+# Read
+
+::: stacrs.read
diff --git a/python/docs/api/search.md b/python/docs/api/search.md
new file mode 100644
index 00000000..e13de5e3
--- /dev/null
+++ b/python/docs/api/search.md
@@ -0,0 +1,4 @@
+# Search
+
+::: stacrs.search
+::: stacrs.search_to
diff --git a/python/docs/api/validate.md b/python/docs/api/validate.md
new file mode 100644
index 00000000..df2481f3
--- /dev/null
+++ b/python/docs/api/validate.md
@@ -0,0 +1,4 @@
+# Validate
+
+::: stacrs.validate
+::: stacrs.validate_href
diff --git a/python/docs/api/write.md b/python/docs/api/write.md
new file mode 100644
index 00000000..9c18bb2b
--- /dev/null
+++ b/python/docs/api/write.md
@@ -0,0 +1,3 @@
+# Write
+
+::: stacrs.write
diff --git a/python/mkdocs.yml b/python/mkdocs.yml
index a2c69f09..0b2cfc90 100644
--- a/python/mkdocs.yml
+++ b/python/mkdocs.yml
@@ -5,9 +5,18 @@ theme:
   name: material
   icon:
     repo: fontawesome/brands/github
+  features:
+    - navigation.indexes
 nav:
   - Home: index.md
-  - API: api.md
+  - API:
+      - api/index.md
+      - migrate: api/migrate.md
+      - read: api/read.md
+      - search: api/search.md
+      - validate: api/validate.md
+      - write: api/write.md
+      - pystac: api/pystac.md
 plugins:
   - search
   - mkdocstrings:
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 70d472bc..3d73ed8d 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -22,6 +22,9 @@ classifiers = [
 keywords = ["stac", "geospatial"]
 dynamic = ["version"]
 
+[project.optional-dependencies]
+pystac = ["pystac>=1"]
+
 [project.urls]
 Repository = "https://github.com/stac-utils/stac-rs"
 Documentation = "https://stacrs.readthedocs.io/"
@@ -29,6 +32,7 @@ Issues = "https://github.com/stac-utils/stac-rs/issues"
 
 [tool.maturin]
 features = ["pyo3/extension-module"]
+python-source = "python"
 
 [[tool.mypy.overrides]]
 module = "pyarrow.*"
diff --git a/python/python/stacrs/__init__.py b/python/python/stacrs/__init__.py
new file mode 100644
index 00000000..1abac7c4
--- /dev/null
+++ b/python/python/stacrs/__init__.py
@@ -0,0 +1,30 @@
+from . import stacrs
+from .stacrs import (
+    migrate,
+    migrate_href,
+    read,
+    search,
+    search_to,
+    validate,
+    validate_href,
+    write,
+)
+
+__doc__ = stacrs.__doc__
+__all__ = [
+    "migrate",
+    "migrate_href",
+    "read",
+    "search",
+    "search_to",
+    "validate",
+    "validate_href",
+    "write",
+]
+
+import importlib.util
+
+if importlib.util.find_spec("pystac") is not None:
+    from . import pystac as pystac
+
+    __all__.append("pystac")
diff --git a/python/python/stacrs/py.typed b/python/python/stacrs/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/python/python/stacrs/pystac.py b/python/python/stacrs/pystac.py
new file mode 100644
index 00000000..e8f925d5
--- /dev/null
+++ b/python/python/stacrs/pystac.py
@@ -0,0 +1,245 @@
+from typing import Any, Iterable, Optional
+
+import pystac
+from pystac import Catalog, Item, ItemCollection, STACObject
+
+from . import stacrs
+
+
+def migrate(
+    value: STACObject,
+    version: Optional[str] = None,
+    *,
+    include_self_link: bool = True,
+    transform_hrefs: bool = False,
+) -> dict[str, Any]:
+    """
+    Migrates a STAC dictionary to another version.
+
+    Because pystac doesn't store the STAC version on the STACObject, this
+    function returns a dictionary to ensure the version is preserved.
+
+    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 (STACObject): 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.
+        include_self_link (bool): Whether to include a self link when
+            serializing this object to a dictionary.
+        transform_hrefs (bool): Whether to transform this object's hrefs when
+            serializing it to a dictionary. While pystac defaults to true, this
+            function defaults to false
+            (https://github.com/stac-utils/pystac/issues/960)
+
+    Returns:
+        dict[str, Any]: The migrated object
+
+    Examples:
+        >>> item = pystac.read_file("old-item.json")
+        >>> item = stacrs.pystac.migrate(item, "1.1.0")
+        >>> assert item["stac_version"] == "1.1.0"
+    """
+    return stacrs.migrate(
+        value.to_dict(
+            include_self_link=include_self_link, transform_hrefs=transform_hrefs
+        ),
+        version,
+    )
+
+
+def read(
+    href: str,
+    *,
+    format: str | None = None,
+    options: list[tuple[str, str]] | None = None,
+    root: Catalog | None = None,
+) -> STACObject | ItemCollection:
+    """
+    Reads STAC from a href.
+
+    Args:
+        href (str): The href to write to
+        format (str | None): The output format to write. If not provided, will be
+            inferred from the href's extension.
+        options (list[tuple[str, str]] | None): Options for configuring an
+            object store, e.g. your AWS credentials.
+        root (Catalog | None): Optional root of the catalog for this object. If
+            provided, the root's resolved object cache can be used to search for
+            previously resolved instances of the STAC object.
+
+    Returns:
+        STACObject | ItemCollection: The STAC value
+
+    Examples:
+        >>> item = stacrs.read("item.json")
+    """
+    d = stacrs.read(href, format=format, options=options)
+    if d.get("type") == "FeatureCollection":
+        return ItemCollection.from_dict(d)
+    else:
+        return pystac.read_dict(d, href=href, root=root)
+
+
+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,
+) -> Iterable[Item]:
+    """
+    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:
+        Iterable[Item]: A iterable of pystac items.
+
+    Examples:
+        >>> items = list(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,
+        ... ))
+    """
+    items = stacrs.search(
+        href,
+        intersects=intersects,
+        ids=ids,
+        collections=collections,
+        max_items=max_items,
+        limit=limit,
+        bbox=bbox,
+        datetime=datetime,
+        include=include,
+        exclude=exclude,
+        sortby=sortby,
+        filter=filter,
+        query=query,
+    )
+    return (Item.from_dict(d) for d in items)
+
+
+def validate(
+    value: STACObject,
+    *,
+    include_self_link: bool = True,
+    transform_hrefs: bool = False,
+) -> None:
+    """
+    Validates a STAC dictionary with json-schema.
+
+    Args:
+        value (STACObject): The STAC value to validate
+        include_self_link (bool): Whether to include a self link when
+            serializing this object to a dictionary.
+        transform_hrefs (bool): Whether to transform this object's hrefs when
+            serializing it to a dictionary. While pystac defaults to true, this
+            function defaults to false
+            (https://github.com/stac-utils/pystac/issues/960)
+
+    Raises:
+        Exception: On a validation error
+
+    Examples:
+        >>> item = pystac.read_file("examples/simple-item.json")
+        >>> stacrs.pystac.validate(item)
+    """
+    return stacrs.validate(
+        value.to_dict(
+            include_self_link=include_self_link, transform_hrefs=transform_hrefs
+        )
+    )
+
+
+def write(
+    href: str,
+    value: STACObject | list[Item],
+    *,
+    format: str | None = None,
+    options: list[tuple[str, str]] | None = None,
+    include_self_link: bool = True,
+    transform_hrefs: bool = False,
+) -> dict[str, str] | None:
+    """
+    Writes STAC to a href.
+
+    Args:
+        href (str): The href to write to
+        value (dict[str, Any] | list[dict[str, Any]]): The value to write. This
+            can be a STAC dictionary or a list of items.
+        format (str | None): The output format to write. If not provided, will be
+            inferred from the href's extension.
+        options (list[tuple[str, str]] | None): Options for configuring an
+            object store, e.g. your AWS credentials.
+        include_self_link (bool): Whether to include a self link when
+            serializing this object to a dictionary.
+        transform_hrefs (bool): Whether to transform this object's hrefs when
+            serializing it to a dictionary. While pystac defaults to true, this
+            function defaults to false
+            (https://github.com/stac-utils/pystac/issues/960)
+
+    Returns:
+        dict[str, str] | None: The result of putting data into an object store,
+            e.g. the e_tag and the version. None is returned if the file was written
+            locally.
+
+    Examples:
+        >>> with open("simple-item.json") as f:
+        ...     item = json.load(f)
+        >>> stacrs.write("out.parquet", [item])
+    """
+    if isinstance(value, list):
+        d = ItemCollection(value).to_dict(transform_hrefs=transform_hrefs)
+    else:
+        d = value.to_dict(
+            include_self_link=include_self_link, transform_hrefs=transform_hrefs
+        )
+    return stacrs.write(str(href), d, format=format, options=options)
diff --git a/python/stacrs.pyi b/python/python/stacrs/stacrs.pyi
similarity index 81%
rename from python/stacrs.pyi
rename to python/python/stacrs/stacrs.pyi
index 0c1cbcbd..dd7d9257 100644
--- a/python/stacrs.pyi
+++ b/python/python/stacrs/stacrs.pyi
@@ -2,6 +2,12 @@ from typing import Any, Optional, Tuple
 
 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 read(
+    href: str,
+    *,
+    format: str | None = None,
+    options: list[tuple[str, str]] | None = None,
+) -> dict[str, Any]: ...
 def search(
     href: str,
     *,
@@ -36,6 +42,13 @@ def search_to(
     query: Optional[dict[str, Any]] = None,
     format: Optional[str] = None,
     options: Optional[list[Tuple[str, str]]] = None,
-) -> list[dict[str, Any]]: ...
+) -> None: ...
 def validate_href(href: str) -> None: ...
 def validate(value: dict[str, Any]) -> None: ...
+def write(
+    href: str,
+    value: dict[str, Any] | list[dict[str, Any]],
+    *,
+    format: str | None = None,
+    options: list[tuple[str, str]] | None = None,
+) -> dict[str, str] | None: ...
diff --git a/python/src/lib.rs b/python/src/lib.rs
index 28fca6df..524eb996 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -2,8 +2,10 @@
 
 mod error;
 mod migrate;
+mod read;
 mod search;
 mod validate;
+mod write;
 
 use error::Error;
 use pyo3::prelude::*;
@@ -15,9 +17,11 @@ type Result<T> = std::result::Result<T, Error>;
 fn stacrs(m: &Bound<'_, PyModule>) -> PyResult<()> {
     m.add_function(wrap_pyfunction!(migrate::migrate, m)?)?;
     m.add_function(wrap_pyfunction!(migrate::migrate_href, m)?)?;
+    m.add_function(wrap_pyfunction!(read::read, 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)?)?;
+    m.add_function(wrap_pyfunction!(write::write, m)?)?;
     Ok(())
 }
diff --git a/python/src/read.rs b/python/src/read.rs
new file mode 100644
index 00000000..68c16b44
--- /dev/null
+++ b/python/src/read.rs
@@ -0,0 +1,44 @@
+use crate::Error;
+use pyo3::{
+    pyfunction,
+    types::{PyAnyMethods, PyDict},
+    Bound, PyErr, PyResult, Python,
+};
+use stac::{Format, Value};
+use tokio::runtime::Builder;
+
+/// Reads STAC from a href.
+///
+/// Args:
+///     href (str): The href to write to
+///     format (str | None): The output format to write. If not provided, will be
+///         inferred from the href's extension.
+///     options (list[tuple[str, str]] | None): Options for configuring an
+///         object store, e.g. your AWS credentials.
+///
+/// Returns:
+///     dict[str, Any]: The STAC value
+///
+/// Examples:
+///     >>> item = stacrs.read("item.json")
+#[pyfunction]
+#[pyo3(signature = (href, *, format=None, options=None))]
+pub fn read<'py>(
+    py: Python<'py>,
+    href: String,
+    format: Option<String>,
+    options: Option<Vec<(String, String)>>,
+) -> PyResult<Bound<'py, PyDict>> {
+    let format = format
+        .and_then(|f| f.parse::<Format>().ok())
+        .or_else(|| Format::infer_from_href(&href))
+        .unwrap_or_default();
+    let options = options.unwrap_or_default();
+    let runtime = Builder::new_current_thread().enable_all().build()?;
+    let value = runtime
+        .block_on(async move { format.get_opts::<Value, _, _, _>(href, options).await })
+        .map_err(Error::from)?;
+    pythonize::pythonize(py, &value)
+        .map_err(PyErr::from)
+        .and_then(|v| v.extract())
+}
diff --git a/python/src/validate.rs b/python/src/validate.rs
index b06267e1..beeae69c 100644
--- a/python/src/validate.rs
+++ b/python/src/validate.rs
@@ -9,14 +9,14 @@ use stac_validate::ValidateBlocking;
 ///     href (str): The href of the STAC value to validate
 ///
 /// Raises:
-///     StacrsError: On any input/output error, or on a validation error
+///     Exception: 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
+///     Exception: Validation errors: "collection" is a required property
 #[pyfunction]
 pub fn validate_href(href: &str) -> Result<()> {
     let value: Value = stac::read(href)?;
@@ -29,7 +29,7 @@ pub fn validate_href(href: &str) -> Result<()> {
 ///     value (dict[str, Any]): The STAC value to validate
 ///
 /// Raises:
-///     StacrsError: On a validation error
+///     Exception: On a validation error
 ///
 /// Examples:
 ///     >>> with open("examples/simple-item.json") as f:
diff --git a/python/src/write.rs b/python/src/write.rs
new file mode 100644
index 00000000..9d3ddee1
--- /dev/null
+++ b/python/src/write.rs
@@ -0,0 +1,74 @@
+use crate::{Error, Result};
+use pyo3::{
+    pyfunction,
+    types::{PyAnyMethods, PyDict},
+    Bound, PyAny, PyResult, Python,
+};
+use serde_json::Value;
+use stac::{Format, Item, ItemCollection};
+use tokio::runtime::Builder;
+
+/// Writes STAC to a href.
+///
+/// Args:
+///     href (str): The href to write to
+///     value (dict[str, Any] | list[dict[str, Any]]): The value to write. This
+///         can be a STAC dictionary or a list of items.
+///     format (str | None): The output format to write. If not provided, will be
+///         inferred from the href's extension.
+///     options (list[tuple[str, str]] | None): Options for configuring an
+///         object store, e.g. your AWS credentials.
+///
+/// Returns:
+///     dict[str, str] | None: The result of putting data into an object store,
+///         e.g. the e_tag and the version. None is returned if the file was written
+///         locally.
+///
+/// Examples:
+///     >>> with open("simple-item.json") as f:
+///     ...     item = json.load(f)
+///     >>> stacrs.write("out.parquet", [item])
+#[pyfunction]
+#[pyo3(signature = (href, value, *, format=None, options=None))]
+pub fn write<'py>(
+    py: Python<'py>,
+    href: String,
+    value: Bound<'_, PyAny>,
+    format: Option<String>,
+    options: Option<Vec<(String, String)>>,
+) -> PyResult<Option<Bound<'py, PyDict>>> {
+    let value: Value = pythonize::depythonize(&value)?;
+    let value = if let Value::Array(array) = value {
+        let items = array
+            .into_iter()
+            .map(|value| serde_json::from_value::<Item>(value).map_err(Error::from))
+            .collect::<Result<Vec<_>>>()?;
+        stac::Value::ItemCollection(ItemCollection::from(items))
+    } else {
+        serde_json::from_value(value).map_err(Error::from)?
+    };
+    let format = format
+        .and_then(|f| f.parse::<Format>().ok())
+        .or_else(|| Format::infer_from_href(&href))
+        .unwrap_or_default();
+    let runtime = Builder::new_current_thread().enable_all().build()?;
+    let put_result = runtime
+        .block_on(async move {
+            format
+                .put_opts(href, value, options.unwrap_or_default())
+                .await
+        })
+        .map_err(Error::from)?;
+    if let Some(put_result) = put_result {
+        let dict = PyDict::new_bound(py);
+        if let Some(e_tag) = put_result.e_tag {
+            dict.set_item("e_tag", e_tag)?;
+        }
+        if let Some(version) = put_result.version {
+            dict.set_item("version", version)?;
+        }
+        Ok(Some(dict))
+    } else {
+        Ok(None)
+    }
+}


Test patch:
diff --git a/python/tests/conftest.py b/python/tests/conftest.py
index fc1fc277..92ce91e0 100644
--- a/python/tests/conftest.py
+++ b/python/tests/conftest.py
@@ -12,7 +12,12 @@ def root() -> Path:
 
 @pytest.fixture
 def spec_examples(root: Path) -> Path:
-    return root / "spec-examples" / "v1.0.0"
+    return root / "spec-examples"
+
+
+@pytest.fixture
+def examples(root: Path) -> Path:
+    return root / "python" / "examples"
 
 
 @pytest.fixture
@@ -21,6 +26,6 @@ def data(root: Path) -> Path:
 
 
 @pytest.fixture
-def item(spec_examples: Path) -> dict[str, Any]:
-    with open(spec_examples / "simple-item.json") as f:
+def item(examples: Path) -> dict[str, Any]:
+    with open(examples / "simple-item.json") as f:
         return json.load(f)
diff --git a/python/tests/test_migrate.py b/python/tests/test_migrate.py
index 13e5a812..c905ece6 100644
--- a/python/tests/test_migrate.py
+++ b/python/tests/test_migrate.py
@@ -5,12 +5,12 @@
 
 
 def test_migrate(item: dict[str, Any]) -> None:
-    item = stacrs.migrate(item, version="1.1.0-beta.1")
-    assert item["stac_version"] == "1.1.0-beta.1"
+    item = stacrs.migrate(item, version="1.1.0")
+    assert item["stac_version"] == "1.1.0"
 
 
 def test_migrate_href(spec_examples: Path) -> None:
     item = stacrs.migrate_href(
-        str(spec_examples / "simple-item.json"), version="1.1.0-beta.1"
+        str(spec_examples / "v1.0.0" / "simple-item.json"), version="1.1.0"
     )
-    assert item["stac_version"] == "1.1.0-beta.1"
+    assert item["stac_version"] == "1.1.0"
diff --git a/python/tests/test_pystac.py b/python/tests/test_pystac.py
new file mode 100644
index 00000000..1717f8dd
--- /dev/null
+++ b/python/tests/test_pystac.py
@@ -0,0 +1,44 @@
+import pytest
+
+_ = pytest.importorskip("pystac")
+
+from pathlib import Path  # noqa
+
+import stacrs.pystac  # noqa
+from pystac import Item  # noqa
+
+
+@pytest.fixture
+def item(root: Path) -> Item:
+    return Item.from_file(root / "python" / "examples" / "simple-item.json")
+
+
+def test_search() -> None:
+    items = stacrs.pystac.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(list(items)) == 1
+
+
+def test_validate(item: Item) -> None:
+    stacrs.pystac.validate(item)
+
+
+def test_migrate(spec_examples: Path) -> None:
+    item = Item.from_file(spec_examples / "v1.0.0" / "simple-item.json")
+    d = stacrs.pystac.migrate(item)
+    assert d["stac_version"] == "1.1.0"
+
+
+def test_write(tmp_path: Path, item: Item) -> None:
+    path = tmp_path / "out.ndjson"
+    stacrs.pystac.write(str(path), [item])
+
+
+def test_read(examples: Path, root: Path) -> None:
+    stacrs.pystac.read(str(examples / "simple-item.json"))
+    stacrs.pystac.read(str(root / "core" / "data" / "extended-item.parquet"))
diff --git a/python/tests/test_read.py b/python/tests/test_read.py
new file mode 100644
index 00000000..ea3cb356
--- /dev/null
+++ b/python/tests/test_read.py
@@ -0,0 +1,7 @@
+from pathlib import Path
+
+import stacrs
+
+
+def test_read(examples: Path) -> None:
+    stacrs.read(str(examples / "simple-item.json"))
diff --git a/python/tests/test_validate.py b/python/tests/test_validate.py
index 4fb1e476..4dffbb49 100644
--- a/python/tests/test_validate.py
+++ b/python/tests/test_validate.py
@@ -5,8 +5,8 @@
 import stacrs
 
 
-def test_validate_href_ok(spec_examples: Path) -> None:
-    stacrs.validate_href(str(spec_examples / "simple-item.json"))
+def test_validate_href_ok(examples: Path) -> None:
+    stacrs.validate_href(str(examples / "simple-item.json"))
 
 
 def test_validate_href_invalid(data: Path) -> None:
diff --git a/python/tests/test_write.py b/python/tests/test_write.py
new file mode 100644
index 00000000..b1018585
--- /dev/null
+++ b/python/tests/test_write.py
@@ -0,0 +1,14 @@
+from pathlib import Path
+from typing import Any
+
+import pyarrow.parquet
+import stac_geoparquet
+import stacrs
+
+
+def test_write(item: dict[str, Any], tmp_path: Path) -> None:
+    path = str(tmp_path / "out.parquet")
+    stacrs.write(path, [item])
+    table = pyarrow.parquet.read_table(path)
+    items = list(stac_geoparquet.arrow.stac_table_to_items(table))
+    assert len(items) == 1