From cb0fa3884c036e36b4ece86b57248e1a0dc7f08d Mon Sep 17 00:00:00 2001 From: jeafreezy Date: Sun, 27 Sep 2026 11:05:14 +0200 Subject: [PATCH] Prepare 0.2.0 Rust and Python release --- .github/workflows/CI.yml | 270 ++++++------------ .github/workflows/publish.yml | 75 +++-- CHANGELOG.md | 20 ++ Cargo.lock | 12 +- Cargo.toml | 2 +- crates/cli/Cargo.toml | 8 +- crates/config/Cargo.toml | 2 +- crates/core/tests/storages/memory.rs | 2 +- crates/engine/Cargo.toml | 4 +- crates/python/Cargo.toml | 10 +- crates/python/LICENSE | 21 ++ crates/python/README.md | 18 +- crates/python/pyproject.toml | 9 +- crates/python/python/superstac/_superstac.pyi | 10 +- crates/python/src/search.rs | 2 +- crates/python/src/shared.rs | 5 +- crates/python/src/utils.rs | 4 +- crates/python/tests/test_wheel.py | 142 +++++++++ crates/search/Cargo.toml | 2 +- docs/app/docs/layout.tsx | 2 +- docs/app/page.tsx | 2 +- docs/content/docs/reference/status.md | 2 +- docs/content/docs/rust/overview.md | 10 +- docs/content/docs/start/installation.md | 2 +- docs/deploy/package-releases.md | 51 ++++ scripts/check-release-version.py | 17 ++ scripts/publish-crates.py | 27 ++ 27 files changed, 493 insertions(+), 238 deletions(-) create mode 100644 crates/python/LICENSE create mode 100644 crates/python/tests/test_wheel.py create mode 100644 docs/deploy/package-releases.md create mode 100644 scripts/check-release-version.py create mode 100644 scripts/publish-crates.py diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index bbfcb51..40f9c52 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,181 +1,99 @@ -# # This file is autogenerated by maturin v1.7.4 -# # To update, run -# # -# # maturin generate-ci github -# # -# name: CI +name: Package checks -# on: -# push: -# branches: -# - main -# - master -# tags: -# - '*' -# pull_request: -# workflow_dispatch: +on: + pull_request: + paths: ['crates/**', 'Cargo.toml', 'Cargo.lock', 'scripts/**', '.github/workflows/CI.yml', '.github/workflows/publish.yml'] + push: + branches: [main] + paths: ['crates/**', 'Cargo.toml', 'Cargo.lock', 'scripts/**', '.github/workflows/CI.yml', '.github/workflows/publish.yml'] + workflow_dispatch: + workflow_call: -# permissions: -# contents: read +permissions: + contents: read -# jobs: -# linux: -# runs-on: ${{ matrix.platform.runner }} -# strategy: -# matrix: -# platform: -# - runner: ubuntu-latest -# target: x86_64 -# - runner: ubuntu-latest -# target: x86 -# - runner: ubuntu-latest -# target: aarch64 -# - runner: ubuntu-latest -# target: armv7 -# - runner: ubuntu-latest -# target: s390x -# - runner: ubuntu-latest -# target: ppc64le -# steps: -# - uses: actions/checkout@v4 -# - uses: actions/setup-python@v5 -# with: -# python-version: 3.x -# - name: Build wheels -# uses: PyO3/maturin-action@v1 -# with: -# target: ${{ matrix.platform.target }} -# args: --release --out dist --find-interpreter -# sccache: 'true' -# manylinux: auto -# - name: Upload wheels -# uses: actions/upload-artifact@v4 -# with: -# name: wheels-linux-${{ matrix.platform.target }} -# path: dist +jobs: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python scripts/check-release-version.py + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.88.0' + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace --exclude superstac-python --locked -# musllinux: -# runs-on: ${{ matrix.platform.runner }} -# strategy: -# matrix: -# platform: -# - runner: ubuntu-latest -# target: x86_64 -# - runner: ubuntu-latest -# target: x86 -# - runner: ubuntu-latest -# target: aarch64 -# - runner: ubuntu-latest -# target: armv7 -# steps: -# - uses: actions/checkout@v4 -# - uses: actions/setup-python@v5 -# with: -# python-version: 3.x -# - name: Build wheels -# uses: PyO3/maturin-action@v1 -# with: -# target: ${{ matrix.platform.target }} -# args: --release --out dist --find-interpreter -# sccache: 'true' -# manylinux: musllinux_1_2 -# - name: Upload wheels -# uses: actions/upload-artifact@v4 -# with: -# name: wheels-musllinux-${{ matrix.platform.target }} -# path: dist + wheels: + name: Wheel (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - runner: macos-15-intel + target: x86_64-apple-darwin + - runner: macos-14 + target: aarch64-apple-darwin + - runner: windows-latest + target: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + rust-toolchain: '1.88.0' + maturin-version: '1.13.3' + manylinux: '2_28' + args: --release --locked --manifest-path crates/python/Cargo.toml --out dist + - name: Check and install the wheel + shell: bash + run: | + python -m pip install twine + python -m twine check --strict dist/* + python -m pip install --no-deps --force-reinstall dist/*.whl + - name: Test the installed wheel + run: python -m unittest discover -s crates/python/tests -v + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.target }} + path: dist/*.whl + if-no-files-found: error -# windows: -# runs-on: ${{ matrix.platform.runner }} -# strategy: -# matrix: -# platform: -# - runner: windows-latest -# target: x64 -# - runner: windows-latest -# target: x86 -# steps: -# - uses: actions/checkout@v4 -# - uses: actions/setup-python@v5 -# with: -# python-version: 3.x -# architecture: ${{ matrix.platform.target }} -# - name: Build wheels -# uses: PyO3/maturin-action@v1 -# with: -# target: ${{ matrix.platform.target }} -# args: --release --out dist --find-interpreter -# sccache: 'true' -# - name: Upload wheels -# uses: actions/upload-artifact@v4 -# with: -# name: wheels-windows-${{ matrix.platform.target }} -# path: dist - -# macos: -# runs-on: ${{ matrix.platform.runner }} -# strategy: -# matrix: -# platform: -# - runner: macos-12 -# target: x86_64 -# - runner: macos-14 -# target: aarch64 -# steps: -# - uses: actions/checkout@v4 -# - uses: actions/setup-python@v5 -# with: -# python-version: 3.x -# - name: Build wheels -# uses: PyO3/maturin-action@v1 -# with: -# target: ${{ matrix.platform.target }} -# args: --release --out dist --find-interpreter -# sccache: 'true' -# - name: Upload wheels -# uses: actions/upload-artifact@v4 -# with: -# name: wheels-macos-${{ matrix.platform.target }} -# path: dist - -# sdist: -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v4 -# - name: Build sdist -# uses: PyO3/maturin-action@v1 -# with: -# command: sdist -# args: --out dist -# - name: Upload sdist -# uses: actions/upload-artifact@v4 -# with: -# name: wheels-sdist -# path: dist - -# release: -# name: Release -# runs-on: ubuntu-latest -# if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} -# needs: [linux, musllinux, windows, macos, sdist] -# permissions: -# # Use to sign the release artifacts -# id-token: write -# # Used to upload release artifacts -# contents: write -# # Used to generate artifact attestation -# attestations: write -# steps: -# - uses: actions/download-artifact@v4 -# - name: Generate artifact attestation -# uses: actions/attest-build-provenance@v1 -# with: -# subject-path: 'wheels-*/*' -# - name: Publish to PyPI -# if: "startsWith(github.ref, 'refs/tags/')" -# uses: PyO3/maturin-action@v1 -# env: -# MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} -# with: -# command: upload -# args: --non-interactive --skip-existing wheels-*/* + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.9' + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.88.0' + - uses: PyO3/maturin-action@v1 + with: + command: sdist + maturin-version: '1.13.3' + args: --manifest-path crates/python/Cargo.toml --out dist + - name: Build and test from the source archive on Python 3.9 + run: | + python -m pip install twine + python -m twine check --strict dist/* + python -m pip wheel --no-deps --wheel-dir rebuilt dist/*.tar.gz + python -m pip install --no-deps rebuilt/*.whl + python -m unittest discover -s crates/python/tests -v + - uses: actions/upload-artifact@v4 + with: + name: python-sdist + path: dist/*.tar.gz + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1a721db..f0f7b59 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,23 +1,62 @@ -# name: Publish to PyPI +name: Publish packages -# on: -# push: -# tags: -# - "v*.*.*" +on: + release: + types: [published] -# jobs: -# build: -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v4 +permissions: + contents: read -# - name: Install uv -# run: curl -LsSf https://astral.sh/uv/install.sh | sh +concurrency: + group: publish-packages + cancel-in-progress: false -# - name: Build package -# run: uv build +jobs: + checks: + uses: ./.github/workflows/CI.yml -# - name: Publish to PyPI -# uses: pypa/gh-action-pypi-publish@release/v1 -# with: -# password: ${{ secrets.PYPI_API_TOKEN }} + crates: + needs: checks + runs-on: ubuntu-latest + environment: crates-io + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.88.0' + - name: Publish Rust crates in dependency order + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + test -n "$CARGO_REGISTRY_TOKEN" || { echo 'Set CARGO_REGISTRY_TOKEN in Actions secrets'; exit 1; } + python scripts/check-release-version.py + python scripts/publish-crates.py + + pypi: + needs: [checks, crates] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/superstac + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: wheels-* + merge-multiple: true + path: dist + - uses: actions/download-artifact@v4 + with: + name: python-sdist + path: dist + - name: Publish Python distributions + uses: pypa/gh-action-pypi-publish@release/v1 + with: + # Omit the secret to use PyPI Trusted Publishing; existing API tokens also work. + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + attestations: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d0f9d..299b58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ releases. ## [Unreleased] +## [0.2.0] - 2026-09-27 + +### Added +- First Rust-backed Python distribution with synchronous `Client`, `AsyncClient`, + native dictionary results, configuration, discovery, and search metadata. +- Documentation at https://spatialnode.com/superstac, with Python, Rust, CLI, + configuration guides, and a runnable notebook. +- Release automation for Python wheels, a source distribution, and Rust crates. + +### Changed +- The PyPI package replaces the legacy 0.1.0a2 Python implementation. This is a + breaking migration; use the new client API rather than assuming compatibility. +- Python requires CPython 3.9+. Maturin is a build dependency only. +- All Rust crate versions advance together to 0.2.0 and link to the docs site. + +### Fixed +- Python bindings import configuration types from their current Rust module. +- Python `Search` now returns an iterator from `__iter__`. +- Python type hints include constructor keyword options and `to_geojson()`. + ## [0.1.0] - 2026-05-21 Initial public release. diff --git a/Cargo.lock b/Cargo.lock index 8a0010a..7eb9600 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,7 +2674,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "superstac-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "serde", @@ -2691,7 +2691,7 @@ dependencies = [ [[package]] name = "superstac-config" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde-saphyr", @@ -2701,7 +2701,7 @@ dependencies = [ [[package]] name = "superstac-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "serde", @@ -2712,7 +2712,7 @@ dependencies = [ [[package]] name = "superstac-engine" -version = "0.1.0" +version = "0.2.0" dependencies = [ "parking_lot", "reqwest", @@ -2727,7 +2727,7 @@ dependencies = [ [[package]] name = "superstac-python" -version = "0.1.0" +version = "0.2.0" dependencies = [ "parking_lot", "pyo3", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "superstac-search" -version = "0.1.0" +version = "0.2.0" dependencies = [ "futures", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 0e2ea79..ba72d2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 32718bb..f84b685 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -18,10 +18,10 @@ name = "superstac" path = "src/main.rs" [dependencies] -superstac-core = { path = "../core", version = "0.1.0" } -superstac-config = { path = "../config", version = "0.1.0" } -superstac-search = { path = "../search", version = "0.1.0" } -superstac-engine = { path = "../engine", version = "0.1.0" } +superstac-core = { path = "../core", version = "0.2.0" } +superstac-config = { path = "../config", version = "0.2.0" } +superstac-search = { path = "../search", version = "0.2.0" } +superstac-engine = { path = "../engine", version = "0.2.0" } clap = { version = "4", features = ["derive"] } serde = "1" serde_json = "1" diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index dd4207f..085b190 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -14,7 +14,7 @@ documentation.workspace = true authors.workspace = true [dependencies] -superstac-core = { path = "../core", version = "0.1.0" } +superstac-core = { path = "../core", version = "0.2.0" } serde = { version = "1.0", features = ["derive"] } serde-saphyr = "0.0.26" tracing = "0.1" diff --git a/crates/core/tests/storages/memory.rs b/crates/core/tests/storages/memory.rs index cb591b5..452ee8c 100644 --- a/crates/core/tests/storages/memory.rs +++ b/crates/core/tests/storages/memory.rs @@ -1073,7 +1073,7 @@ fn memory_store_returns_matched_providers_when_filtered() { Some("A different STAC Provider".to_string()), Some("https://test.com".to_string()), - Some("A test version".to_string()), + Some("https://test.com/logo.png".to_string()), None, ) .expect("Provider should be created"), diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 386a50b..84373e7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -14,8 +14,8 @@ documentation.workspace = true authors.workspace = true [dependencies] -superstac-core = { path = "../core", version = "0.1.0" } -superstac-search = { path = "../search", version = "0.1.0" } +superstac-core = { path = "../core", version = "0.2.0" } +superstac-search = { path = "../search", version = "0.2.0" } serde = { version = "1.0", features = ["derive"] } stac = "0.16" stac-io = "0.2" diff --git a/crates/python/Cargo.toml b/crates/python/Cargo.toml index 8e9a27b..61679f2 100644 --- a/crates/python/Cargo.toml +++ b/crates/python/Cargo.toml @@ -3,7 +3,7 @@ name = "superstac-python" description = "Python bindings for SuperSTAC via PyO3." keywords = ["stac", "geospatial", "federated", "satellite", "python"] categories = ["science::geo"] -readme = "../../README.md" +readme = "README.md" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -19,10 +19,10 @@ name = "_superstac" crate-type = ["cdylib"] [dependencies] -superstac-core = { path = "../core", version = "0.1.0" } -superstac-config = { path = "../config", version = "0.1.0" } -superstac-engine = { path = "../engine", version = "0.1.0" } -superstac-search = { path = "../search", version = "0.1.0" } +superstac-core = { path = "../core", version = "0.2.0" } +superstac-config = { path = "../config", version = "0.2.0" } +superstac-engine = { path = "../engine", version = "0.2.0" } +superstac-search = { path = "../search", version = "0.2.0" } pyo3 = { version = "0.26", features = ["extension-module", "abi3-py39"] } pyo3-async-runtimes = { version = "0.26", features = ["tokio-runtime"] } pythonize = "0.26" diff --git a/crates/python/LICENSE b/crates/python/LICENSE new file mode 100644 index 0000000..4b9f458 --- /dev/null +++ b/crates/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Emmanuel Jolaiya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/python/README.md b/crates/python/README.md index 68eb7f8..75e460a 100644 --- a/crates/python/README.md +++ b/crates/python/README.md @@ -16,8 +16,9 @@ This package provides the Python bindings for [SuperSTAC](https://github.com/spa > **Status: alpha.** APIs are not yet stable. Pre-1.0; expect breaking changes. -`superstac` ships a sync `Client` (drop-in for `pystac_client.Client` in most -code) and an `AsyncClient` for asyncio users. +`superstac` provides a synchronous `Client` and an `AsyncClient` for asyncio. +Results are Python dictionaries. Some method names resemble pystac-client, but +this is not a drop-in replacement. ## Documentation @@ -31,6 +32,15 @@ Read the [SuperSTAC documentation](https://spatialnode.com/superstac) for instal Try the [Python quickstart notebook](https://spatialnode.com/superstac/docs/python/notebook) for a two-catalog search, footprint map, and GeoJSON export in Colab or Jupyter. +## Upgrading from 0.1.0a2 + +Version 0.2.0 replaces the old Python implementation with a Rust engine. +Use `from superstac import Client, AsyncClient` and follow the +[Python guide](https://spatialnode.com/superstac/docs/python/overview) to migrate. +Results are dictionaries, `matched()` counts returned items, and `to_geojson()` +exports a FeatureCollection. Custom authentication hooks and the full +pystac-client API are not supported. CPython 3.9 or newer is required. + ## Install ```bash @@ -48,7 +58,7 @@ maturin develop ## Quickstart -### Drop-in for pystac-client (single catalog) +### Search a single catalog ```python from superstac import Client @@ -66,7 +76,7 @@ for item in search.items(): print(item["id"], item["properties"]["datetime"]) print(search.matched(), "items") -fc = search.as_geojson() # GeoJSON FeatureCollection dict +fc = search.to_geojson() # GeoJSON FeatureCollection dict ``` ### Federated across multiple catalogs diff --git a/crates/python/pyproject.toml b/crates/python/pyproject.toml index a58cde2..9f3cb3d 100644 --- a/crates/python/pyproject.toml +++ b/crates/python/pyproject.toml @@ -7,27 +7,28 @@ name = "superstac" description = "Many catalogs. One search. Search across STAC catalogs with Python." readme = "README.md" license = "MIT" +license-files = ["LICENSE"] authors = [{ name = "Emmanuel Jolaiya", email = "emmanuel@spatialnode.com" }] -requires-python = ">=3.7" +requires-python = ">=3.9" keywords = ["stac", "geospatial", "federated", "satellite"] classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Rust", "Topic :: Scientific/Engineering :: GIS", ] dynamic = ["version"] -dependencies = [ - "maturin", -] +dependencies = [] [project.urls] Homepage = "https://spatialnode.com/superstac" Documentation = "https://spatialnode.com/superstac" Repository = "https://github.com/spatialnode/superstac" +Changelog = "https://github.com/spatialnode/superstac/blob/main/CHANGELOG.md" [tool.maturin] # Build into the `superstac` Python package; the compiled module is diff --git a/crates/python/python/superstac/_superstac.pyi b/crates/python/python/superstac/_superstac.pyi index cfaf5d2..9d17a92 100644 --- a/crates/python/python/superstac/_superstac.pyi +++ b/crates/python/python/superstac/_superstac.pyi @@ -4,8 +4,10 @@ __version__: str class Search: + def __iter__(self) -> Iterator[dict[str, Any]]: ... + def to_geojson(self) -> dict[str, Any]: ... def matched(self) -> int: ... - def items(self) -> Iterator[dict[str, Any]]: ... + def items(self) -> list[dict[str, Any]]: ... def item_collection_as_dict(self) -> dict[str, Any]: ... @property def metadata(self) -> dict[str, Any]: ... @@ -16,6 +18,9 @@ class Client: self, config: dict[str, Any] | None = None, *, + catalogs: list[dict[str, Any]] | None = None, + providers: list[dict[str, Any]] | None = None, + settings: dict[str, Any] | None = None, storage: str = "memory", ) -> None: ... @classmethod @@ -75,6 +80,9 @@ class AsyncClient: self, config: dict[str, Any] | None = None, *, + catalogs: list[dict[str, Any]] | None = None, + providers: list[dict[str, Any]] | None = None, + settings: dict[str, Any] | None = None, storage: str = "memory", ) -> None: ... @classmethod diff --git a/crates/python/src/search.rs b/crates/python/src/search.rs index 57141c6..4e18408 100644 --- a/crates/python/src/search.rs +++ b/crates/python/src/search.rs @@ -84,7 +84,7 @@ impl PySearch { } fn __iter__<'py>(&mut self, py: Python<'py>) -> PyResult> { - Ok(self.ensure_items(py)?.into_any()) + self.ensure_items(py)?.call_method0("__iter__") } fn __repr__(&self) -> String { diff --git a/crates/python/src/shared.rs b/crates/python/src/shared.rs index ef0e234..93a2322 100644 --- a/crates/python/src/shared.rs +++ b/crates/python/src/shared.rs @@ -5,10 +5,11 @@ use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::{PyDict, PyList}; +use superstac_config::config::{CatalogConfig, CatalogProviderConfig}; use superstac_core::{ models::{ - catalog::{Catalog, CatalogConfig, CatalogUpdate}, - provider::{CatalogProvider, CatalogProviderConfig, CatalogProviderUpdate}, + catalog::{Catalog, CatalogUpdate}, + provider::{CatalogProvider, CatalogProviderUpdate}, settings::SettingsUpdate, }, storages::factory::StorageBackend, diff --git a/crates/python/src/utils.rs b/crates/python/src/utils.rs index def33b1..d17046f 100644 --- a/crates/python/src/utils.rs +++ b/crates/python/src/utils.rs @@ -8,12 +8,12 @@ pub fn err_to_py(e: SuperSTACError) -> PyErr { PyRuntimeError::new_err(e.to_string()) } -/// Parse a storage backend from a string. Only "memory" is supported in v0.1, but this is where other backends would be added in the future. +/// Parse a storage backend from a string. Only "memory" is supported, but this is where other backends would be added in the future. pub fn parse_storage_kind(kind: &str) -> PyResult { match kind.to_ascii_lowercase().as_str() { "memory" => Ok(Storage::Memory), other => Err(PyValueError::new_err(format!( - "unknown storage backend '{}'. Only 'memory' is supported in v0.1.", + "unknown storage backend '{}'. Only 'memory' is supported.", other ))), } diff --git a/crates/python/tests/test_wheel.py b/crates/python/tests/test_wheel.py new file mode 100644 index 0000000..f92bc2b --- /dev/null +++ b/crates/python/tests/test_wheel.py @@ -0,0 +1,142 @@ +"""Integration checks against the installed wheel, using local STAC fixtures.""" +import asyncio +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib.metadata import metadata, version +import json +from pathlib import Path +import threading +import unittest + +import superstac +from superstac import AsyncClient, Client + + +class CatalogHandler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_HEAD(self): + self.send_response(200) + self.end_headers() + + def do_POST(self): + self.rfile.read(int(self.headers.get("Content-Length", 0))) + self.do_GET() + + def do_GET(self): + path = self.path.split("?", 1)[0] + status = 200 + if path.endswith("/collections"): + response = {"collections": [{ + "type": "Collection", "stac_version": "1.0.0", "id": "test-scenes", + "description": "Local test collection", "license": "proprietary", "links": [], + "extent": {"spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [[None, None]]}}, + }], "links": []} + elif path.endswith("/search"): + if path.startswith("/broken"): + status, response = 503, {"error": "fixture unavailable"} + else: + catalog = path.split("/")[1] + response = {"type": "FeatureCollection", "links": [], "features": [ + {"type": "Feature", "stac_version": "1.0.0", "id": item_id, + "collection": "test-scenes", "geometry": {"type": "Point", "coordinates": [0, 0]}, + "bbox": [0, 0, 0, 0], "properties": {"datetime": "2026-01-01T00:00:00Z"}, + "links": [], "assets": {}} + for item_id in ["shared", catalog] + ]} + else: + response = {"type": "Catalog", "stac_version": "1.0.0", "id": "fixture", "description": "Fixture", "links": []} + data = json.dumps(response).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +class WheelTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), CatalogHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join() + + def catalogs(self, *ids): + return [{"id": id, "url": f"http://127.0.0.1:{self.server.server_port}/{id}", + "settings": {"health_check_strategy": "hourly", "healthy_status_code_range": [200, 299], + "enable_background_health_monitor": False}} for id in ids] + + def client(self, cls=Client, ids=("first", "second")): + return cls(catalogs=self.catalogs(*ids), settings={ + "logging_enabled": False, "per_catalog_timeout_seconds": 2, + "max_retry_attempts": 1, "search_healthy_catalogs_only": False, + }) + + def check_results(self, result): + self.assertEqual({item["id"] for item in result.items()}, {"shared", "first", "second"}) + self.assertEqual(len(result), 3) + self.assertEqual(result.matched(), 3) + self.assertEqual(list(result), result.items()) + self.assertEqual(list(result), list(result)) + self.assertEqual(result.to_geojson(), result.item_collection_as_dict()) + self.assertEqual(result.to_geojson()["type"], "FeatureCollection") + self.assertEqual(result.metadata["catalogs_failed"], 0) + self.assertEqual(result.metadata["catalogs_queried"], 2) + + def test_installed_metadata(self): + self.assertEqual(superstac.__version__, version("superstac")) + self.assertEqual(metadata("superstac")["Requires-Python"], ">=3.9") + self.assertFalse(metadata("superstac").get_all("Requires-Dist")) + package = Path(superstac.__file__).parent + self.assertTrue((package / "py.typed").is_file()) + self.assertTrue((package / "_superstac.pyi").is_file()) + + def test_sync_federation_and_iteration(self): + client = self.client() + try: + self.check_results(client.search(collections=["test-scenes"], limit=5)) + finally: + client.shutdown() + + def test_async_federation(self): + async def run(): + client = self.client(AsyncClient) + try: + self.check_results(await client.search(collections=["test-scenes"], limit=5)) + finally: + await client.shutdown() + asyncio.run(run()) + + def test_partial_failure(self): + client = self.client(ids=("first", "broken")) + try: + result = client.search(collections=["test-scenes"], limit=5) + self.assertEqual(len(result), 2) + self.assertEqual(result.metadata["catalogs_failed"], 1) + self.assertEqual(result.metadata["failures"][0]["catalog_id"], "broken") + finally: + client.shutdown() + + def test_configuration_and_invalid_storage(self): + with self.assertRaises(ValueError): + Client(storage="sqlite") + client = self.client() + try: + self.assertEqual(len(client.list_catalogs()), 2) + client.delete_catalog("second") + self.assertEqual(len(client.list_catalogs()), 1) + client.update_settings({"max_items_per_catalog": 7}) + self.assertEqual(client.get_settings()["max_items_per_catalog"], 7) + finally: + client.shutdown() + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index a9d157b..d9f649a 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -14,7 +14,7 @@ documentation.workspace = true authors.workspace = true [dependencies] -superstac-core = { path = "../core", version = "0.1.0" } +superstac-core = { path = "../core", version = "0.2.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["time"] } diff --git a/docs/app/docs/layout.tsx b/docs/app/docs/layout.tsx index 7d50e5d..d689d51 100644 --- a/docs/app/docs/layout.tsx +++ b/docs/app/docs/layout.tsx @@ -4,7 +4,7 @@ import { baseOptions } from '@/lib/layout.shared'; export default function Layout({ children }: { children: React.ReactNode }) { return ( - v0.1 · Alpha }}> + v0.2 · Alpha }}> {children} ); diff --git a/docs/app/page.tsx b/docs/app/page.tsx index bd39aa7..acabc64 100644 --- a/docs/app/page.tsx +++ b/docs/app/page.tsx @@ -22,7 +22,7 @@ export default function Home() {
- v0.1 · Alpha + v0.2 · Alpha

Many catalogs.
One search.

Search across STAC catalogs through one interface, with Python, Rust, or the command line.

diff --git a/docs/content/docs/reference/status.md b/docs/content/docs/reference/status.md index 2294f39..bfa1c93 100644 --- a/docs/content/docs/reference/status.md +++ b/docs/content/docs/reference/status.md @@ -3,7 +3,7 @@ title: Status and limitations description: What the current alpha supports and what is still being built. --- -SuperSTAC is a pre-1.0, MIT-licensed project. This documentation describes the current repository API; there may be differences from published artifacts. APIs and YAML shapes may change. +SuperSTAC is a pre-1.0, MIT-licensed project. This documentation targets version 0.2.0. APIs and YAML shapes may change. ## Available diff --git a/docs/content/docs/rust/overview.md b/docs/content/docs/rust/overview.md index 2a9610e..ece2e12 100644 --- a/docs/content/docs/rust/overview.md +++ b/docs/content/docs/rust/overview.md @@ -9,14 +9,14 @@ description: Embed SuperSTAC in an asynchronous Rust application with explicit c ```toml title="Cargo.toml" [dependencies] -superstac-core = "0.1" -superstac-config = "0.1" -superstac-search = "0.1" -superstac-engine = "0.1" +superstac-core = "0.2" +superstac-config = "0.2" +superstac-search = "0.2" +superstac-engine = "0.2" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` -For this checkout's unreleased API, replace version dependencies with paths such as `superstac-engine = { path = "../superstac/crates/engine" }`. Use Rust 1.88 or newer as declared by the workspace. +Use Rust 1.88 or newer as declared by the workspace. These examples target SuperSTAC 0.2. ## A complete search diff --git a/docs/content/docs/start/installation.md b/docs/content/docs/start/installation.md index b332817..b6f2a64 100644 --- a/docs/content/docs/start/installation.md +++ b/docs/content/docs/start/installation.md @@ -41,7 +41,7 @@ cargo add superstac-core superstac-config superstac-search superstac-engine cargo add tokio --features macros,rt-multi-thread ``` -These docs describe the working tree's 0.1 API. For unreleased changes, use path dependencies to a local checkout instead of assuming that a registry release has the same API. See the [Rust guide](/docs/rust/overview/). +These docs target SuperSTAC 0.2. See the [Rust guide](/docs/rust/overview/). ## Command-line tool diff --git a/docs/deploy/package-releases.md b/docs/deploy/package-releases.md new file mode 100644 index 0000000..3ac6cbb --- /dev/null +++ b/docs/deploy/package-releases.md @@ -0,0 +1,51 @@ +# Package releases + +`Publish packages` runs when a GitHub release is published. Rust tests, five +native wheel builds (Linux x86-64/ARM64, macOS Intel/Apple Silicon, Windows x86-64), +installed-wheel tests, and a source-archive rebuild must pass before publication. +Wheels use the CPython 3.9+ stable ABI. Linux wheels require glibc 2.28+; other +platforms can build from the source distribution. + +## Credentials + +- `CARGO_REGISTRY_TOKEN`: repository Actions secret (or `crates-io` environment), + with publishing rights for the five existing SuperSTAC crates. +- PyPI Trusted Publisher on the existing `superstac` project: owner `Spatialnode`, + repository `superstac`, workflow `publish.yml`, environment `pypi`. + Alternatively, set a project-scoped `PYPI_API_TOKEN` Actions secret. +- GitHub environments may inherit repository secrets. Keep tokens out of files. + +## Releasing + +1. Update the workspace version, internal Cargo dependency constraints, Cargo.lock, + changelog, migration notes, and docs version labels. Python reads its version + from Cargo. +2. Merge the release PR after `Package checks` passes. That workflow also supports + manual runs, producing artifacts without publishing. +3. Tag the checked commit `v` and publish its GitHub release. The workflow + checks the tag against the workspace version. +4. Check `Publish packages` and the independently triggered `Deploy docs` workflow. + Confirm versions and documentation links on PyPI and crates.io before announcing. + +Crates publish in dependency order: core, config, search, engine, cli. The Python +extension's Cargo package has `publish = false` and goes to PyPI only. Publication +cannot be atomic across registries. Rerun after fixing credentials or registry +availability; existing crate versions and Python files are skipped. Source +changes after publication require a new version and tag. + +Docs may deploy before package publication. The package remains alpha software +even when the numeric version has no `a` suffix. + +## Local verification + +```bash +cargo test --workspace --exclude superstac-python --locked +maturin build --release --locked --manifest-path crates/python/Cargo.toml --out dist +maturin sdist --manifest-path crates/python/Cargo.toml --out dist +# In a clean environment with Python >=3.9: +python -m pip install --no-deps dist/*.whl +python -m unittest discover -s crates/python/tests -v +``` + +Tests use local HTTP fixtures and no public STAC services. Always test the installed +wheel, not a checkout's extension module. diff --git a/scripts/check-release-version.py b/scripts/check-release-version.py new file mode 100644 index 0000000..80a20e8 --- /dev/null +++ b/scripts/check-release-version.py @@ -0,0 +1,17 @@ +"""Check the package versions and, on release runs, the Git tag. Python 3.11+.""" +import os +from pathlib import Path +import tomllib + +root = Path(__file__).resolve().parents[1] +workspace = tomllib.loads((root / "Cargo.toml").read_text()) +version = workspace["workspace"]["package"]["version"] +if os.environ.get("GITHUB_REF_TYPE") == "tag": + assert os.environ["GITHUB_REF_NAME"] == f"v{version}", "Release tag must match Cargo workspace version" +for path in (root / "crates").glob("*/Cargo.toml"): + manifest = tomllib.loads(path.read_text()) + assert manifest["package"]["version"] == {"workspace": True}, path + for name, dep in manifest.get("dependencies", {}).items(): + if name.startswith("superstac-"): + assert dep["version"] == version, (path, name) +print(f"Release versions consistent: {version}") diff --git a/scripts/publish-crates.py b/scripts/publish-crates.py new file mode 100644 index 0000000..d702fcf --- /dev/null +++ b/scripts/publish-crates.py @@ -0,0 +1,27 @@ +"""Publish missing workspace crates in dependency order; safe to rerun after partial success.""" +import json +from pathlib import Path +import subprocess +import tomllib +import urllib.error +import urllib.request + +root = Path(__file__).resolve().parents[1] +version = tomllib.loads((root / "Cargo.toml").read_text())["workspace"]["package"]["version"] +for crate in ("core", "config", "search", "engine", "cli"): + name = f"superstac-{crate}" + request = urllib.request.Request( + f"https://crates.io/api/v1/crates/{name}/{version}", + headers={"User-Agent": "SuperSTAC release workflow (github.com/Spatialnode/superstac)"}, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + existing = json.load(response) + if existing["version"]["yanked"]: + raise RuntimeError(f"{name} {version} is yanked; choose a new version") + print(f"Already published: {name} {version}", flush=True) + continue + except urllib.error.HTTPError as error: + if error.code != 404: + raise + subprocess.run(["cargo", "publish", "--locked", "-p", name], cwd=root, check=True)