Repository: developmentseed/cql2-rs (language: python)

Issue:
Add docs
## What I am changing

- Add docs

## How I did it

- **mkdocs-material** and **mike**
- I put the Python docs in the stubs file
- Since it's a small repo, I KISS-ed it to a single **mkdocs** build — I figure we can keep all the versions (CLI, Python, and Rust) the same, and if we want to break that we can break out the docs later?

## How you can test it

- https://developmentseed.org/cql2-rs/ — I didn't mean to deploy to our core site (I thought I'd just go to https://developmentseed.github.io/cql-rs) but I guess that domain redirects? Kinda spooky 👻 

## Related Issues

- Closes #31 
- Closes #35 as a drive-by, except for `cargo fmt` which doesn't support `--workspace`

Add validate to Python API



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6b98b6..1a22d9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ## [Unreleased]
 
+### Added
+
+- Python bindings 🐍 ([#30](https://github.com/developmentseed/cql2-rs/pull/30))
+- Docs ([#36](https://github.com/developmentseed/cql2-rs/pull/36))
+
+### Changed
+
+- `SqlQuery` attributes are now public ([#30](https://github.com/developmentseed/cql2-rs/pull/30))
+- `Expr::to_json`, `Expr::to_json_pretty`, and `Expr::to_value` now return `Error` instead of `serde_json::Error` ([#37](https://github.com/developmentseed/cql2-rs/pull/37))
+
 ## [0.1.0] - 2024-10-08
 
 Initial release.
diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md
deleted file mode 100644
index d6b98b6..0000000
--- a/cli/CHANGELOG.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [Unreleased]
-
-## [0.1.0] - 2024-10-08
-
-Initial release.
-
-[Unreleased]: https://github.com/developmentseed/cql-rs/compare/v0.1.0...main
-[0.1.0]: https://github.com/developmentseed/cql-rs/releases/tag/v0.1.0
diff --git a/docs/cli.md b/docs/cli.md
index a682379..e88b218 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -4,8 +4,13 @@
 
 ## Installation
 
-Install [Rust](https://rustup.rs/).
-Then:
+Install from [PyPI](https://pypi.org/project/cql2/):
+
+```shell
+pip install cql2
+```
+
+Or, if you have [Rust](https://rustup.rs/):
 
 ```shell
 cargo install cql2-cli
diff --git a/docs/python.md b/docs/python.md
index 1525bbf..3bc9c68 100644
--- a/docs/python.md
+++ b/docs/python.md
@@ -11,3 +11,4 @@ python -m pip install cql2
 
 ::: cql2.Expr
 ::: cql2.SqlQuery
+::: cql2.ValidationError
diff --git a/python/cql2.pyi b/python/cql2.pyi
index 359814b..ac1c3dd 100644
--- a/python/cql2.pyi
+++ b/python/cql2.pyi
@@ -41,6 +41,18 @@ class Expr:
             >>> expr = Expr({"op":"=","args":[{"property":"landsat:scene_id"},"LC82030282019133LGN00"]})
         """
 
+    def validate(self) -> None:
+        """Validates this expression using json-schema.
+
+        Raises:
+            ValidationError: Raised if the validation fails
+
+        Examples:
+            >>> from cql2 import Expr
+            >>> expr = Expr("landsat:scene_id = 'LC82030282019133LGN00'")
+            >>> expr.validate()
+        """
+
     def to_json(self) -> dict[str, Any]:
         """Converts this cql2 expression to a cql2-json dictionary.
 
@@ -81,3 +93,6 @@ class Expr:
             >>> q.params
             ['LC82030282019133LGN00']
         """
+
+class ValidationError(Exception):
+    """An error raised when cql2 json-schema validation fails."""
diff --git a/python/src/lib.rs b/python/src/lib.rs
index 2e242e8..461792c 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -1,9 +1,21 @@
 use pyo3::{
+    create_exception,
     exceptions::{PyException, PyIOError, PyValueError},
     prelude::*,
 };
 use std::path::PathBuf;
 
+create_exception!(cql2, ValidationError, PyException);
+
+/// Crate-specific error enum.
+enum Error {
+    Cql2(::cql2::Error),
+    Pythonize(pythonize::PythonizeError),
+}
+
+/// Crate specific result type.
+type Result<T> = std::result::Result<T, Error>;
+
 /// A CQL2 expression.
 #[pyclass]
 struct Expr(::cql2::Expr);
@@ -22,34 +34,43 @@ struct SqlQuery {
 impl Expr {
     /// Parses a CQL2 expression from a file path.
     #[staticmethod]
-    fn from_path(path: PathBuf) -> PyResult<Expr> {
-        ::cql2::parse_file(path).map(Expr).map_err(to_py_error)
+    fn from_path(path: PathBuf) -> Result<Expr> {
+        ::cql2::parse_file(path).map(Expr).map_err(Error::from)
     }
 
     /// Parses a CQL2 expression.
     #[new]
-    fn new(cql2: Bound<'_, PyAny>) -> PyResult<Self> {
+    fn new(cql2: Bound<'_, PyAny>) -> Result<Self> {
         if let Ok(s) = cql2.extract::<&str>() {
-            s.parse().map(Expr).map_err(to_py_error)
+            s.parse().map(Expr).map_err(Error::from)
         } else {
             let expr: ::cql2::Expr = pythonize::depythonize(&cql2)?;
             Ok(Expr(expr))
         }
     }
 
+    fn validate(&self) -> PyResult<()> {
+        let validator = ::cql2::Validator::new().map_err(Error::from)?;
+        if let Err(error) = validator.validate(&self.0.to_value().map_err(Error::from)?) {
+            Err(ValidationError::new_err(error.to_string()))
+        } else {
+            Ok(())
+        }
+    }
+
     /// Converts this expression to a cql2-json dictionary.
-    fn to_json<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
-        pythonize::pythonize(py, &self.0).map_err(PyErr::from)
+    fn to_json<'py>(&self, py: Python<'py>) -> Result<Bound<'py, PyAny>> {
+        pythonize::pythonize(py, &self.0).map_err(Error::from)
     }
 
     /// Converts this expression to cql2-text.
-    fn to_text(&self) -> PyResult<String> {
-        self.0.to_text().map_err(to_py_error)
+    fn to_text(&self) -> Result<String> {
+        self.0.to_text().map_err(Error::from)
     }
 
     /// Converts this expression to SQL query.
-    fn to_sql(&self) -> PyResult<SqlQuery> {
-        self.0.to_sql().map(SqlQuery::from).map_err(to_py_error)
+    fn to_sql(&self) -> Result<SqlQuery> {
+        self.0.to_sql().map(SqlQuery::from).map_err(Error::from)
     }
 }
 
@@ -62,24 +83,42 @@ impl From<::cql2::SqlQuery> for SqlQuery {
     }
 }
 
-fn to_py_error(error: ::cql2::Error) -> PyErr {
-    use ::cql2::Error::*;
-    match error {
-        InvalidCql2Text(..)
-        | InvalidNumberOfArguments { .. }
-        | MissingArgument(..)
-        | ParseBool(..)
-        | ParseFloat(..)
-        | ParseInt(..) => PyValueError::new_err(error.to_string()),
-        Io(err) => PyIOError::new_err(err.to_string()),
-        _ => PyException::new_err(error.to_string()),
+impl From<Error> for PyErr {
+    fn from(error: Error) -> PyErr {
+        use ::cql2::Error::*;
+        match error {
+            Error::Cql2(error) => match error {
+                InvalidCql2Text(..)
+                | InvalidNumberOfArguments { .. }
+                | MissingArgument(..)
+                | ParseBool(..)
+                | ParseFloat(..)
+                | ParseInt(..) => PyValueError::new_err(error.to_string()),
+                Io(err) => PyIOError::new_err(err.to_string()),
+                _ => PyException::new_err(error.to_string()),
+            },
+            Error::Pythonize(error) => error.into(),
+        }
+    }
+}
+
+impl From<::cql2::Error> for Error {
+    fn from(value: ::cql2::Error) -> Self {
+        Error::Cql2(value)
+    }
+}
+
+impl From<pythonize::PythonizeError> for Error {
+    fn from(value: pythonize::PythonizeError) -> Self {
+        Error::Pythonize(value)
     }
 }
 
 /// A Python module implemented in Rust.
 #[pymodule]
-fn cql2(m: &Bound<'_, PyModule>) -> PyResult<()> {
+fn cql2(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
     m.add_class::<Expr>()?;
     m.add_class::<SqlQuery>()?;
+    m.add("ValidationError", py.get_type_bound::<ValidationError>())?;
     Ok(())
 }
diff --git a/src/expr.rs b/src/expr.rs
index 49c6e85..b490715 100644
--- a/src/expr.rs
+++ b/src/expr.rs
@@ -211,8 +211,8 @@ impl Expr {
     /// let expr = Expr::Bool(true);
     /// let s = expr.to_json().unwrap();
     /// ```
-    pub fn to_json(&self) -> Result<String, serde_json::Error> {
-        serde_json::to_string(&self)
+    pub fn to_json(&self) -> Result<String, Error> {
+        serde_json::to_string(&self).map_err(Error::from)
     }
 
     /// Converts this expression to a pretty JSON string.
@@ -225,8 +225,8 @@ impl Expr {
     /// let expr = Expr::Bool(true);
     /// let s = expr.to_json_pretty().unwrap();
     /// ```
-    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
-        serde_json::to_string_pretty(&self)
+    pub fn to_json_pretty(&self) -> Result<String, Error> {
+        serde_json::to_string_pretty(&self).map_err(Error::from)
     }
 
     /// Converts this expression to a [serde_json::Value].
@@ -239,8 +239,8 @@ impl Expr {
     /// let expr = Expr::Bool(true);
     /// let value = expr.to_value().unwrap();
     /// ```
-    pub fn to_value(&self) -> Result<Value, serde_json::Error> {
-        serde_json::to_value(self)
+    pub fn to_value(&self) -> Result<Value, Error> {
+        serde_json::to_value(self).map_err(Error::from)
     }
 
     /// Returns true if this expression is valid CQL2.
diff --git a/src/validator.rs b/src/validator.rs
index 667662b..dca0cae 100644
--- a/src/validator.rs
+++ b/src/validator.rs
@@ -48,7 +48,8 @@ impl Validator {
     /// validator.validate(&valid).unwrap();
     ///
     /// let invalid = json!({
-    ///     "op": "not an operator!",
+    ///     "op": "t_before",
+    ///     "args": [{"property": "updated_at"}, {"timestamp": "invalid-timestamp"}],
     /// });
     /// validator.validate(&invalid).unwrap_err();
     /// ```


Test patch:
diff --git a/python/tests/test_expr.py b/python/tests/test_expr.py
index 671533e..815ad10 100644
--- a/python/tests/test_expr.py
+++ b/python/tests/test_expr.py
@@ -1,6 +1,7 @@
 from pathlib import Path
 
-from cql2 import Expr
+import pytest
+from cql2 import Expr, ValidationError
 
 
 def test_from_path(fixtures: Path) -> None:
@@ -30,3 +31,14 @@ def test_to_sql(example01_text: str) -> None:
     sql_query = Expr(example01_text).to_sql()
     assert sql_query.query == '("landsat:scene_id" = $1)'
     assert sql_query.params == ["LC82030282019133LGN00"]
+
+
+def test_validate() -> None:
+    expr = Expr(
+        {
+            "op": "t_before",
+            "args": [{"property": "updated_at"}, {"timestamp": "invalid-timestamp"}],
+        }
+    )
+    with pytest.raises(ValidationError):
+        expr.validate()