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

Issue:
Wrap the CLI into the Python package



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c6a2fd89..afa6374c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,7 @@ jobs:
           - "-p stac -p stac-api -F geo"
           - "-p stac-async"
           - "-p stac-cli --no-default-features"
+          - "-p stac-cli --no-default-features -F geoparquet"
           - "-p stac-duckdb -F duckdb/bundled"
           - "-p stac-server"
           - "-p stac-server -F axum"
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index 53238cc6..9de4433f 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -11,21 +11,20 @@ keywords = ["geospatial", "stac", "metadata", "geo", "raster"]
 categories = ["science", "data-structures"]
 
 [features]
-default = ["gdal", "pgstac"]
+default = ["gdal", "geoparquet", "pgstac"]
 duckdb = ["dep:stac-duckdb"]
 gdal = ["stac/gdal"]
+geoparquet = ["dep:bytes", "stac/geoparquet-compression"]
 pgstac = ["stac-server/pgstac"]
 
 [dependencies]
 axum = "0.7"
-bytes = "1"
+bytes = { version = "1", optional = true }
 clap = { version = "4", features = ["derive"] }
 reqwest = "0.12"
 serde = "1"
 serde_json = "1"
-stac = { version = "0.8", path = "../core", features = [
-    "geoparquet-compression",
-] }
+stac = { version = "0.8", path = "../core" }
 stac-api = { version = "0.4", path = "../api" }
 stac-async = { version = "0.5", path = "../async" }
 stac-duckdb = { version = "0.1", path = "../duckdb", optional = true }
@@ -45,7 +44,7 @@ assert_cmd = "2"
 tokio-test = "0.4"
 
 [[bin]]
-name = "stac"
+name = "stacrs"
 path = "src/main.rs"
 doc = false
 test = false
diff --git a/cli/README.md b/cli/README.md
index 1f3156f7..148aff4a 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -6,7 +6,7 @@
 ![Crates.io](https://img.shields.io/crates/l/stac-cli?style=for-the-badge)
 [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg?style=for-the-badge)](./CODE_OF_CONDUCT)
 
-Command Line Interface (CLI) for [STAC](https://stacspec.org/) built with [stac-rs](https://github.com/stac-utils/stac-rs).
+Command Line Interface (CLI), named `stacrs` for [STAC](https://stacspec.org/) built with [stac-rs](https://github.com/stac-utils/stac-rs).
 
 ![stac-cli gif](./img/stac-cli.gif)
 
@@ -19,6 +19,12 @@ Then:
 cargo install stac-cli
 ```
 
+You can also install it from PyPI, where it is named **stacrs**:
+
+```shell
+pip install stacrs
+```
+
 ### Homebrew
 
 If you use [homebrew](https://brew.sh/), you can use [gadomski's](https://github.com/gadomski/) tap to install:
@@ -29,14 +35,14 @@ brew install gadomski/gadomski/stac
 
 ## Usage
 
-**stac-cli** provides the following subcommands:
+**stacrs** provides the following subcommands:
 
-- `stac item`: create STAC items and combine them into item collections
-- `stac search`: search STAC APIs
-- `stac serve`: serve a STAC API
-- `stac sort`: sort the fields of STAC items, catalogs, and collections
-- `stac translate`: convert STAC values from one format to another
-- `stac validate`: validate STAC items, catalogs, and collections using [json-schema](https://json-schema.org/)
+- `stacrs item`: create STAC items and combine them into item collections
+- `stacrs search`: search STAC APIs
+- `stacrs serve`: serve a STAC API
+- `stacrs sort`: sort the fields of STAC items, catalogs, and collections
+- `stacrs translate`: convert STAC values from one format to another
+- `stacrs validate`: validate STAC items, catalogs, and collections using [json-schema](https://json-schema.org/)
 
 Use the `--help` flag to see all available options for the CLI and the subcommands:
 
diff --git a/cli/src/format.rs b/cli/src/format.rs
index 055fe70f..6b530ebf 100644
--- a/cli/src/format.rs
+++ b/cli/src/format.rs
@@ -1,12 +1,11 @@
 use crate::{Error, Result};
-use bytes::Bytes;
 use clap::ValueEnum;
 use serde::de::DeserializeOwned;
-use std::{fs::File, io::Read};
 
 /// The STAC output format.
 #[derive(Clone, Copy, Debug, Default, ValueEnum)]
 pub enum Format {
+    #[cfg(feature = "geoparquet")]
     /// stac-geoparquet
     Parquet,
 
@@ -24,11 +23,12 @@ impl Format {
     pub(crate) async fn read_href<D: DeserializeOwned>(&self, href: Option<&str>) -> Result<D> {
         if let Some(href) = href.and_then(|href| if href == "-" { None } else { Some(href) }) {
             match *self {
+                #[cfg(feature = "geoparquet")]
                 Format::Parquet => {
                     let item_collection = if let Some(url) = stac::href_to_url(href) {
                         stac::geoparquet::from_reader(reqwest::blocking::get(url)?.bytes()?)?
                     } else {
-                        let file = File::open(href)?;
+                        let file = std::fs::File::open(href)?;
                         stac::geoparquet::from_reader(file)?
                     };
                     serde_json::from_value(serde_json::to_value(item_collection)?)
@@ -38,10 +38,12 @@ impl Format {
             }
         } else {
             match *self {
+                #[cfg(feature = "geoparquet")]
                 Format::Parquet => {
+                    use std::io::Read;
                     let mut buf = Vec::new();
                     let _ = std::io::stdin().read_to_end(&mut buf)?;
-                    let item_collection = stac::geoparquet::from_reader(Bytes::from(buf))?;
+                    let item_collection = stac::geoparquet::from_reader(bytes::Bytes::from(buf))?;
                     serde_json::from_value(serde_json::to_value(item_collection)?)
                         .map_err(Error::from)
                 }
diff --git a/cli/src/runner.rs b/cli/src/runner.rs
index 904449ac..acaedc98 100644
--- a/cli/src/runner.rs
+++ b/cli/src/runner.rs
@@ -46,6 +46,7 @@ where
                         writeln!(self.writer, "{}", value)?;
                     }
                 }
+                #[cfg(feature = "geoparquet")]
                 Format::Parquet => {
                     if let Some(value) = value.to_stac() {
                         stac::geoparquet::to_writer(&mut self.writer, value)?;
diff --git a/python/Cargo.toml b/python/Cargo.toml
index 32b5f5a2..3a84c76f 100644
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -13,7 +13,10 @@ name = "stacrs"
 crate-type = ["cdylib"]
 
 [dependencies]
+clap = "4"
 pyo3 = "0.22"
 pythonize = "0.22"
 stac = { path = "../core", features = ["reqwest"] }
+stac-cli = { path = "../cli", default-features = false }
 stac-validate = { path = "../validate" }
+tokio = { version = "1", features = ["rt-multi-thread"] }
diff --git a/python/docs/index.md b/python/docs/index.md
index d90b1168..782b124f 100644
--- a/python/docs/index.md
+++ b/python/docs/index.md
@@ -1,6 +1,6 @@
 # stacrs
 
-A small, no-dependency Python library for working with [STAC](https://stacspec.org), using [Rust](https://github.com/stac-utils/stac-rs) under the hood.
+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
 
@@ -10,6 +10,8 @@ pip install stacrs
 
 ## Usage
 
+### API
+
 ```python
 import stacrs
 
@@ -17,3 +19,46 @@ stacrs.validate_href("https://raw.githubusercontent.com/radiantearth/stac-spec/m
 ```
 
 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/pyproject.toml b/python/pyproject.toml
index eff7893e..85222f18 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -27,5 +27,8 @@ Repository = "https://github.com/stac-utils/stac-rs"
 Documentation = "https://stacrs.readthedocs.io/"
 Issues = "https://github.com/stac-utils/stac-rs/issues"
 
+[project.scripts]
+stacrs = "stacrs:main"
+
 [tool.maturin]
 features = ["pyo3/extension-module"]
diff --git a/python/src/lib.rs b/python/src/lib.rs
index 66579025..bfc5a289 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -1,9 +1,36 @@
+use clap::Parser;
 use pyo3::{create_exception, exceptions::PyException, prelude::*, types::PyDict};
 use stac::{Migrate, Value};
+use stac_cli::Args;
 use stac_validate::Validate;
 
 create_exception!(stacrs, StacrsError, PyException, "An error in stacrs");
 
+/// Runs the command-line interface.
+///
+/// Returns:
+///     int: The exit code
+#[pyfunction]
+fn main() -> PyResult<i64> {
+    std::process::exit(
+        tokio::runtime::Builder::new_multi_thread()
+            .enable_all()
+            .build()
+            .unwrap()
+            .block_on(async {
+                // We skip one because the first argument is going to be the python interpreter.
+                let args = Args::parse_from(std::env::args_os().skip(1));
+                match stac_cli::run(args).await {
+                    Ok(()) => 0,
+                    Err(err) => {
+                        eprintln!("ERROR: {}", err);
+                        err.code()
+                    }
+                }
+            }),
+    )
+}
+
 /// Migrates a STAC dictionary to another version.
 ///
 /// Migration can be as simple as updating the `stac_version` attribute, but
@@ -20,6 +47,9 @@ create_exception!(stacrs, StacrsError, PyException, "An error in stacrs");
 ///     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)
@@ -102,6 +132,7 @@ fn validate_value(value: Value) -> PyResult<()> {
 /// 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!(main, m)?)?;
     m.add_function(wrap_pyfunction!(migrate, m)?)?;
     m.add_function(wrap_pyfunction!(validate_href, m)?)?;
     m.add_function(wrap_pyfunction!(validate, m)?)?;
diff --git a/python/stacrs.pyi b/python/stacrs.pyi
index 7d8b4da9..fc163330 100644
--- a/python/stacrs.pyi
+++ b/python/stacrs.pyi
@@ -2,6 +2,7 @@ from typing import Any, Optional
 
 class StacrsError(Exception): ...
 
+def main() -> int: ...
 def migrate(value: dict[str, Any], version: Optional[str] = None) -> dict[str, Any]: ...
 def validate_href(href: str) -> None: ...
 def validate(value: dict[str, Any]) -> None: ...
diff --git a/scripts/validate-stac-geoparquet b/scripts/validate-stac-geoparquet
index 7c81ace1..cea54f44 100755
--- a/scripts/validate-stac-geoparquet
+++ b/scripts/validate-stac-geoparquet
@@ -37,7 +37,7 @@ def clean_report(report: dict[str, Any]) -> dict[str, Any]:
 try:
     # Writing
     subprocess.check_call(
-        ["cargo", "run", "--no-default-features", "--", "translate", path, parquet_path]
+        ["cargo", "run", "--no-default-features", "-F", "geoparquet", "--", "translate", path, parquet_path]
     )
     table = pyarrow.parquet.read_table(parquet_path)
     after = next(stac_geoparquet.arrow.stac_table_to_items(table))
@@ -55,7 +55,7 @@ try:
     table = stac_geoparquet.arrow.parse_stac_items_to_arrow([before])
     stac_geoparquet.arrow.to_parquet(table, parquet_path)
     item_collection = json.loads(subprocess.check_output(
-        ["cargo", "run", "--no-default-features", "--", "translate", parquet_path]
+        ["cargo", "run", "--no-default-features", "-F", "geoparquet", "--", "translate", parquet_path]
     ))
     assert len(item_collection["features"]) == 1
     clean_item(item_collection["features"][0])  # stac-geoparquet writes as a multi-polygon


Test patch:
diff --git a/cli/tests/help.rs b/cli/tests/help.rs
index 02f9098d..ac5d77ae 100644
--- a/cli/tests/help.rs
+++ b/cli/tests/help.rs
@@ -2,6 +2,6 @@ use assert_cmd::Command;
 
 #[test]
 fn help() {
-    let mut command = Command::cargo_bin("stac").unwrap();
+    let mut command = Command::cargo_bin("stacrs").unwrap();
     command.arg("help").assert().success();
 }
diff --git a/cli/tests/item.rs b/cli/tests/item.rs
index 7e762505..346c4b4a 100644
--- a/cli/tests/item.rs
+++ b/cli/tests/item.rs
@@ -3,13 +3,13 @@ use stac::{Item, ItemCollection};
 
 #[test]
 fn item() {
-    let mut command = Command::cargo_bin("stac").unwrap();
+    let mut command = Command::cargo_bin("stacrs").unwrap();
     command.arg("item").arg("an-id").assert().success();
 }
 
 #[test]
 fn item_collection() {
-    let mut command = Command::cargo_bin("stac").unwrap();
+    let mut command = Command::cargo_bin("stacrs").unwrap();
     let item_a = serde_json::to_string(&Item::new("item-a")).unwrap();
     let output = command
         .arg("item")
diff --git a/cli/tests/sort.rs b/cli/tests/sort.rs
index 51c327e8..b492053e 100644
--- a/cli/tests/sort.rs
+++ b/cli/tests/sort.rs
@@ -3,7 +3,7 @@ use std::{fs::File, io::Read};
 
 #[test]
 fn sort_stdin() {
-    let mut command = Command::cargo_bin("stac").unwrap();
+    let mut command = Command::cargo_bin("stacrs").unwrap();
     let mut item = String::new();
     File::open("examples/simple-item.json")
         .unwrap()
diff --git a/cli/tests/validate.rs b/cli/tests/validate.rs
index 9560afbb..c9ae9218 100644
--- a/cli/tests/validate.rs
+++ b/cli/tests/validate.rs
@@ -3,7 +3,7 @@ use std::{fs::File, io::Read};
 
 #[test]
 fn validate() {
-    let mut command = Command::cargo_bin("stac").unwrap();
+    let mut command = Command::cargo_bin("stacrs").unwrap();
     command
         .arg("validate")
         .arg("examples/simple-item.json")
@@ -13,7 +13,7 @@ fn validate() {
 
 #[test]
 fn validate_stdin() {
-    let mut command = Command::cargo_bin("stac").unwrap();
+    let mut command = Command::cargo_bin("stacrs").unwrap();
     let mut item = String::new();
     File::open("examples/simple-item.json")
         .unwrap()