Skip to content

Commit ad6ca2c

Browse files
cpsievertclaude
andcommitted
feat(python): add typed exception classes (ParseError, ValidationError, ReaderError, WriterError)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 89fc340 commit ad6ca2c

3 files changed

Lines changed: 95 additions & 9 deletions

File tree

ggsql-python/python/ggsql/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
Spec,
1616
validate,
1717
execute,
18+
ParseError,
19+
ValidationError,
20+
ReaderError,
21+
WriterError,
1822
)
1923

2024
__all__ = [
@@ -28,6 +32,11 @@
2832
"validate",
2933
"execute",
3034
"render_altair",
35+
# Exceptions
36+
"ParseError",
37+
"ValidationError",
38+
"ReaderError",
39+
"WriterError",
3140
]
3241
__version__ = "0.1.4"
3342

ggsql-python/src/lib.rs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
#![allow(clippy::useless_conversion)]
44

55
use pyo3::prelude::*;
6+
use pyo3::create_exception;
7+
use pyo3::exceptions::PyValueError;
68
use pyo3::types::{PyBytes, PyDict, PyList};
79
use std::io::Cursor;
810

@@ -12,6 +14,28 @@ use ggsql::validate::{validate as rust_validate, ValidationWarning};
1214
use ggsql::writer::{VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter};
1315
use ggsql::GgsqlError;
1416

17+
// ============================================================================
18+
// Custom Exception Classes
19+
// ============================================================================
20+
21+
// All subclass ValueError for backwards compatibility
22+
create_exception!(ggsql, ParseError, PyValueError, "Raised on query syntax errors.");
23+
create_exception!(ggsql, ValidationError, PyValueError, "Raised on semantic validation errors.");
24+
create_exception!(ggsql, ReaderError, PyValueError, "Raised on data source errors.");
25+
create_exception!(ggsql, WriterError, PyValueError, "Raised on output generation errors.");
26+
27+
/// Convert a GgsqlError to the appropriate typed Python exception.
28+
fn ggsql_err_to_py(e: GgsqlError) -> PyErr {
29+
let msg = e.to_string();
30+
match e {
31+
GgsqlError::ParseError(_) => PyErr::new::<ParseError, _>(msg),
32+
GgsqlError::ValidationError(_) => PyErr::new::<ValidationError, _>(msg),
33+
GgsqlError::ReaderError(_) => PyErr::new::<ReaderError, _>(msg),
34+
GgsqlError::WriterError(_) => PyErr::new::<WriterError, _>(msg),
35+
GgsqlError::InternalError(_) => PyErr::new::<PyValueError, _>(msg),
36+
}
37+
}
38+
1539
use polars::prelude::{DataFrame, IpcReader, IpcWriter, SerReader, SerWriter};
1640

1741
// ============================================================================
@@ -175,7 +199,7 @@ macro_rules! try_native_readers {
175199
if let Ok(native) = $reader.downcast::<$native_type>() {
176200
return native.borrow().inner.execute($query)
177201
.map(|s| PySpec { inner: s })
178-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()));
202+
.map_err(ggsql_err_to_py);
179203
}
180204
)*
181205
}};
@@ -225,7 +249,7 @@ impl PyDuckDBReader {
225249
#[new]
226250
fn new(connection: &str) -> PyResult<Self> {
227251
let inner = RustDuckDBReader::from_connection_string(connection)
228-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
252+
.map_err(ggsql_err_to_py)?;
229253
Ok(Self { inner })
230254
}
231255

@@ -255,7 +279,7 @@ impl PyDuckDBReader {
255279
let rust_df = py_to_polars(py, df)?;
256280
self.inner
257281
.register(name, rust_df, replace)
258-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
282+
.map_err(ggsql_err_to_py)
259283
}
260284

261285
/// Unregister a previously registered table.
@@ -272,7 +296,7 @@ impl PyDuckDBReader {
272296
fn unregister(&self, name: &str) -> PyResult<()> {
273297
self.inner
274298
.unregister(name)
275-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
299+
.map_err(ggsql_err_to_py)
276300
}
277301

278302
/// Execute a SQL query and return the result as a DataFrame.
@@ -295,7 +319,7 @@ impl PyDuckDBReader {
295319
let df = self
296320
.inner
297321
.execute_sql(sql)
298-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
322+
.map_err(ggsql_err_to_py)?;
299323
polars_to_py(py, &df)
300324
}
301325

@@ -330,7 +354,7 @@ impl PyDuckDBReader {
330354
self.inner
331355
.execute(query)
332356
.map(|s| PySpec { inner: s })
333-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
357+
.map_err(ggsql_err_to_py)
334358
}
335359
}
336360

@@ -393,7 +417,7 @@ impl PyVegaLiteWriter {
393417
fn render(&self, spec: &PySpec) -> PyResult<String> {
394418
self.inner
395419
.render(&spec.inner)
396-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
420+
.map_err(ggsql_err_to_py)
397421
}
398422
}
399423

@@ -658,7 +682,7 @@ impl PySpec {
658682
#[pyfunction]
659683
fn validate(query: &str) -> PyResult<PyValidated> {
660684
let v = rust_validate(query)
661-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
685+
.map_err(ggsql_err_to_py)?;
662686

663687
Ok(PyValidated {
664688
sql: v.sql().to_string(),
@@ -739,7 +763,7 @@ fn execute(query: &str, reader: &Bound<'_, PyAny>) -> PyResult<PySpec> {
739763
bridge
740764
.execute(query)
741765
.map(|s| PySpec { inner: s })
742-
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))
766+
.map_err(ggsql_err_to_py)
743767
}
744768

745769
// ============================================================================
@@ -748,6 +772,12 @@ fn execute(query: &str, reader: &Bound<'_, PyAny>) -> PyResult<PySpec> {
748772

749773
#[pymodule]
750774
fn _ggsql(m: &Bound<'_, PyModule>) -> PyResult<()> {
775+
// Exceptions
776+
m.add("ParseError", m.py().get_type::<ParseError>())?;
777+
m.add("ValidationError", m.py().get_type::<ValidationError>())?;
778+
m.add("ReaderError", m.py().get_type::<ReaderError>())?;
779+
m.add("WriterError", m.py().get_type::<WriterError>())?;
780+
751781
// Classes
752782
m.add_class::<PyDuckDBReader>()?;
753783
m.add_class::<PyVegaLiteWriter>()?;

ggsql-python/tests/test_ggsql.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,53 @@ def unregister(self, name: str) -> None:
532532
assert "point" in json_output
533533

534534

535+
class TestExceptions:
536+
"""Tests for typed exception classes."""
537+
538+
def test_parse_error_on_invalid_syntax(self):
539+
"""Invalid syntax raises ParseError when executing."""
540+
with pytest.raises(ggsql.ParseError):
541+
reader = ggsql.DuckDBReader("duckdb://memory")
542+
reader.execute("SELECT 1 AS x VISUALISE DRAW not_a_geom")
543+
544+
def test_parse_error_is_value_error(self):
545+
"""ParseError is a subclass of ValueError for backwards compat."""
546+
assert issubclass(ggsql.ParseError, ValueError)
547+
548+
def test_validation_error_on_missing_aesthetic(self):
549+
"""Missing required aesthetic raises ValidationError."""
550+
with pytest.raises(ggsql.ValidationError):
551+
reader = ggsql.DuckDBReader("duckdb://memory")
552+
reader.execute("SELECT 1 AS x VISUALISE DRAW point MAPPING x AS x")
553+
554+
def test_validation_error_is_value_error(self):
555+
"""ValidationError is a subclass of ValueError for backwards compat."""
556+
assert issubclass(ggsql.ValidationError, ValueError)
557+
558+
def test_reader_error_on_bad_sql(self):
559+
"""Bad SQL raises ReaderError."""
560+
with pytest.raises(ggsql.ReaderError):
561+
reader = ggsql.DuckDBReader("duckdb://memory")
562+
reader.execute(
563+
"SELECT * FROM nonexistent_table VISUALISE DRAW point MAPPING x AS x, y AS y"
564+
)
565+
566+
def test_reader_error_is_value_error(self):
567+
"""ReaderError is a subclass of ValueError for backwards compat."""
568+
assert issubclass(ggsql.ReaderError, ValueError)
569+
570+
def test_writer_error_is_value_error(self):
571+
"""WriterError is a subclass of ValueError for backwards compat."""
572+
assert issubclass(ggsql.WriterError, ValueError)
573+
574+
def test_all_exceptions_exported(self):
575+
"""All exception classes are accessible from ggsql module."""
576+
assert hasattr(ggsql, "ParseError")
577+
assert hasattr(ggsql, "ValidationError")
578+
assert hasattr(ggsql, "ReaderError")
579+
assert hasattr(ggsql, "WriterError")
580+
581+
535582
class TestReaderProtocol:
536583
"""Tests for Reader protocol."""
537584

0 commit comments

Comments
 (0)