From d2072968b9df302a2a54ea3768bd5af943d4fee2 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Wed, 28 Aug 2024 13:05:58 -0600 Subject: [PATCH] feat: add python package --- .github/workflows/ci.yml | 2 +- .github/workflows/python.yml | 245 ++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + python/.gitignore | 72 ++++++++++ python/Cargo.toml | 14 ++ python/data/invalid-item.json | 80 +++++++++++ python/pyproject.toml | 16 +++ python/src/lib.rs | 35 +++++ python/stacrs.pyi | 5 + python/tests/conftest.py | 18 +++ python/tests/test_validate.py | 14 ++ 11 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/python.yml create mode 100644 python/.gitignore create mode 100644 python/Cargo.toml create mode 100644 python/data/invalid-item.json create mode 100644 python/pyproject.toml create mode 100644 python/src/lib.rs create mode 100644 python/stacrs.pyi create mode 100644 python/tests/conftest.py create mode 100644 python/tests/test_validate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92c9c22b..f74e4586 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: pull_request: concurrency: - group: ${{ github.ref }} + group: ${{ github.ref }}-ci cancel-in-progress: true env: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..bbd75a09 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,245 @@ +# This file is autogenerated by maturin v1.7.1 +# To update, run +# +# maturin generate-ci github --pytest -m python/Cargo.toml +# +name: Python + +on: + push: + branches: + - main + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.ref }}-python + cancel-in-progress: true + +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 --manifest-path python/Cargo.toml -F openssl-sys/vendored + sccache: 'true' + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + - name: pytest + if: ${{ startsWith(matrix.platform.target, 'x86_64') }} + shell: bash + run: | + set -e + python3 -m venv .venv + source .venv/bin/activate + pip install stacrs --find-links dist --force-reinstall + pip install pytest + cd python && pytest + - name: pytest + if: ${{ !startsWith(matrix.platform.target, 'x86') && matrix.platform.target != 'ppc64' }} + uses: uraimo/run-on-arch-action@v2 + with: + arch: ${{ matrix.platform.target }} + distro: ubuntu22.04 + githubToken: ${{ github.token }} + install: | + apt-get update + apt-get install -y --no-install-recommends python3 python3-pip + pip3 install -U pip pytest + run: | + set -e + pip3 install stacrs --find-links dist --force-reinstall + cd python && pytest + + 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 --manifest-path python/Cargo.toml + sccache: 'true' + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + - name: pytest + if: ${{ startsWith(matrix.platform.target, 'x86_64') }} + uses: addnab/docker-run-action@v3 + with: + image: alpine:latest + options: -v ${{ github.workspace }}:/io -w /io + run: | + set -e + apk add py3-pip py3-virtualenv + python3 -m virtualenv .venv + source .venv/bin/activate + pip install stacrs --no-index --find-links dist --force-reinstall + pip install pytest + cd python && pytest + - name: pytest + if: ${{ !startsWith(matrix.platform.target, 'x86') }} + uses: uraimo/run-on-arch-action@v2 + with: + arch: ${{ matrix.platform.target }} + distro: alpine_latest + githubToken: ${{ github.token }} + install: | + apk add py3-virtualenv + run: | + set -e + python3 -m virtualenv .venv + source .venv/bin/activate + pip install pytest + pip install stacrs --find-links dist --force-reinstall + cd python && pytest + + 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 --manifest-path python/Cargo.toml + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + - name: pytest + if: ${{ !startsWith(matrix.platform.target, 'aarch64') }} + shell: bash + run: | + set -e + python3 -m venv .venv + source .venv/Scripts/activate + pip install stacrs --find-links dist --force-reinstall + pip install pytest + cd python && pytest + + 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 --manifest-path python/Cargo.toml + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + - name: pytest + run: | + set -e + python3 -m venv .venv + source .venv/bin/activate + pip install stacrs --find-links dist --force-reinstall + pip install pytest + cd python && pytest + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist --manifest-path python/Cargo.toml + - 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/python')" + needs: [linux, musllinux, windows, macos, sdist] + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/Cargo.toml b/Cargo.toml index ff248122..3108231d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "stac", "pgstac", + "python", "stac-api", "stac-arrow", "stac-async", @@ -12,6 +13,7 @@ members = [ "stac-validate", ] default-members = [ + "python", "stac", "stac-api", "stac-arrow", diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 00000000..c8f04429 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/python/Cargo.toml b/python/Cargo.toml new file mode 100644 index 00000000..78a47722 --- /dev/null +++ b/python/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "stacrs" +version = "0.0.1" +edition = "2021" +publish = false + +[lib] +name = "stacrs" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = "0.22" +stac = { version = "0.8", path = "../stac", features = ["reqwest"] } +stac-validate = { version = "0.2", path = "../stac-validate" } diff --git a/python/data/invalid-item.json b/python/data/invalid-item.json new file mode 100644 index 00000000..60340c73 --- /dev/null +++ b/python/data/invalid-item.json @@ -0,0 +1,80 @@ +{ + "stac_version": "1.0.0", + "stac_extensions": [], + "type": "Feature", + "id": "20201211_223832_CS2", + "bbox": [ + 172.91173669923782, + 1.3438851951615003, + 172.95469614953714, + 1.3690476620161975 + ], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 172.91173669923782, + 1.3438851951615003 + ], + [ + 172.95469614953714, + 1.3438851951615003 + ], + [ + 172.95469614953714, + 1.3690476620161975 + ], + [ + 172.91173669923782, + 1.3690476620161975 + ], + [ + 172.91173669923782, + 1.3438851951615003 + ] + ] + ] + }, + "properties": { + "datetime": "2020-12-11T22:38:32.125000Z" + }, + "links": [ + { + "rel": "collection", + "href": "./collection.json", + "type": "application/json", + "title": "Simple Example Collection" + }, + { + "rel": "root", + "href": "./collection.json", + "type": "application/json", + "title": "Simple Example Collection" + }, + { + "rel": "parent", + "href": "./collection.json", + "type": "application/json", + "title": "Simple Example Collection" + } + ], + "assets": { + "visual": { + "href": "https://storage.googleapis.com/open-cogs/stac-examples/20201211_223832_CS2.tif", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "title": "3-Band Visual", + "roles": [ + "visual" + ] + }, + "thumbnail": { + "href": "https://storage.googleapis.com/open-cogs/stac-examples/20201211_223832_CS2.jpg", + "title": "Thumbnail", + "type": "image/jpeg", + "roles": [ + "thumbnail" + ] + } + } +} \ No newline at end of file diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..bc60a6bd --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "stacrs" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 00000000..6d794d38 --- /dev/null +++ b/python/src/lib.rs @@ -0,0 +1,35 @@ +use pyo3::{create_exception, exceptions::PyException, prelude::*}; +use stac::Value; +use stac_validate::Validate; + +create_exception!(stacrs, StacrsError, PyException, "An error in stacrs"); + +/// Validates a single href with json-schema. +#[pyfunction] +fn validate_href(href: &str) -> PyResult<()> { + let value: Value = stac::read(href).map_err(|err| StacrsError::new_err(err.to_string()))?; + 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(()) + } +} + +/// 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!(validate_href, m)?)?; + m.add("StacrsError", m.py().get_type_bound::())?; + Ok(()) +} diff --git a/python/stacrs.pyi b/python/stacrs.pyi new file mode 100644 index 00000000..df277144 --- /dev/null +++ b/python/stacrs.pyi @@ -0,0 +1,5 @@ +class StacrsError(Exception): + """Custom exception type for errors originating in the stacrs package.""" + +def validate_href(href: str) -> None: + """Validates a STAC value at the provided href, raising an exception on any validation errors.""" diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 00000000..10f3a0da --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import pytest + + +@pytest.fixture +def root() -> Path: + return Path(__file__).parents[2] + + +@pytest.fixture +def spec_examples(root: Path) -> Path: + return root / "spec-examples" / "v1.0.0" + + +@pytest.fixture +def data(root: Path) -> Path: + return root / "python" / "data" diff --git a/python/tests/test_validate.py b/python/tests/test_validate.py new file mode 100644 index 00000000..0edb7c5a --- /dev/null +++ b/python/tests/test_validate.py @@ -0,0 +1,14 @@ +from pathlib import Path + +import pytest +import stacrs +from stacrs import StacrsError + + +def test_validate_href_ok(spec_examples: Path) -> None: + stacrs.validate_href(str(spec_examples / "simple-item.json")) + + +def test_validate_href_invalid(data: Path) -> None: + with pytest.raises(StacrsError): + stacrs.validate_href(str(data / "invalid-item.json"))