From 16a1a2788c06fd2232b437516b358950741b7e78 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 8 Oct 2026 17:01:40 -0500 Subject: [PATCH 01/17] chore: bump ggsql crate to 0.5.2 and Python package to 0.5.2 Test expectation update: point's position aesthetics became optional in ggsql 0.4.0, so the missing-required-aesthetic test now uses a text layer missing its required label aesthetic. --- Cargo.lock | 692 ++++++++++++++++++++++++++++++++++++--- Cargo.toml | 7 +- pyproject.toml | 2 +- python/ggsql/__init__.py | 2 +- tests/test_ggsql.py | 7 +- uv.lock | 2 +- 6 files changed, 652 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c1c1f7..60ea8de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,9 +83,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e833808ff2d94ed40d9379848a950d995043c7fb3e81a30b383f4c6033821cc" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" dependencies = [ "arrow-arith", "arrow-array", @@ -102,23 +102,23 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad08897b81588f60ba983e3ca39bda2b179bdd84dced378e7df81a5313802ef8" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" dependencies = [ "arrow-array", "arrow-buffer", "arrow-data", "arrow-schema", "chrono", - "num", + "num-traits", ] [[package]] name = "arrow-array" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8548ca7c070d8db9ce7aa43f37393e4bfcf3f2d3681df278490772fd1673d08d" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" dependencies = [ "ahash 0.8.12", "arrow-buffer", @@ -126,30 +126,34 @@ dependencies = [ "arrow-schema", "chrono", "half", - "hashbrown 0.16.1", - "num", + "hashbrown 0.17.0", + "num-complex", + "num-integer", + "num-traits", ] [[package]] name = "arrow-buffer" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e003216336f70446457e280807a73899dd822feaf02087d31febca1363e2fccc" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" dependencies = [ "bytes", "half", - "num", + "num-bigint", + "num-traits", ] [[package]] name = "arrow-cast" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "919418a0681298d3a77d1a315f625916cb5678ad0d74b9c60108eb15fd083023" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" dependencies = [ "arrow-array", "arrow-buffer", "arrow-data", + "arrow-ord", "arrow-schema", "arrow-select", "atoi", @@ -158,27 +162,28 @@ dependencies = [ "comfy-table", "half", "lexical-core", - "num", + "num-traits", "ryu", ] [[package]] name = "arrow-data" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5c64fff1d142f833d78897a772f2e5b55b36cb3e6320376f0961ab0db7bd6d0" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" dependencies = [ "arrow-buffer", "arrow-schema", "half", - "num", + "num-integer", + "num-traits", ] [[package]] name = "arrow-ipc" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3594dcddccc7f20fd069bc8e9828ce37220372680ff638c5e00dea427d88f5" +checksum = "29a908a11fcfb3fb2f6730f4ac15e367bc644e419155e96238f68cf3adde572b" dependencies = [ "arrow-array", "arrow-buffer", @@ -190,9 +195,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8f82583eb4f8d84d4ee55fd1cb306720cddead7596edce95b50ee418edf66f" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" dependencies = [ "arrow-array", "arrow-buffer", @@ -203,9 +208,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d07ba24522229d9085031df6b94605e0f4b26e099fb7cdeec37abd941a73753" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" dependencies = [ "arrow-array", "arrow-buffer", @@ -216,32 +221,32 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3aa9e59c611ebc291c28582077ef25c97f1975383f1479b12f3b9ffee2ffabe" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" dependencies = [ "bitflags", ] [[package]] name = "arrow-select" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c41dbbd1e97bfcaee4fcb30e29105fb2c75e4d82ae4de70b792a5d3f66b2e7a" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" dependencies = [ "ahash 0.8.12", "arrow-array", "arrow-buffer", "arrow-data", "arrow-schema", - "num", + "num-traits", ] [[package]] name = "arrow-string" -version = "56.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f5183c150fbc619eede22b861ea7c0eebed8eaac0333eaa7f6da5205fd504d" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" dependencies = [ "arrow-array", "arrow-buffer", @@ -249,7 +254,7 @@ dependencies = [ "arrow-schema", "arrow-select", "memchr", - "num", + "num-traits", "regex", "regex-syntax", ] @@ -357,6 +362,26 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bytes" version = "1.11.1" @@ -406,12 +431,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clipper2-rust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "210e9ea90a5f6c731c23bf642383ad7c006d8b24803c5c33afd5aaeae5a33f66" +dependencies = [ + "num-traits", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + [[package]] name = "comfy-table" version = "7.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56" dependencies = [ + "crossterm 0.27.0", + "crossterm 0.28.1", "strum 0.26.3", "strum_macros 0.26.4", "unicode-width", @@ -464,6 +506,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -473,6 +524,39 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "parking_lot", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "parking_lot", + "rustix 0.38.44", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -513,14 +597,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "duckdb" -version = "1.4.4" +version = "1.10502.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8685352ce688883098b61a361e86e87df66fc8c444f4a2411e884c16d5243a65" +checksum = "0fdc796383b176dd5a45353fbb5e64583c0ee4da12cb62c9e510b785324b2488" dependencies = [ "arrow", "cast", + "comfy-table", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -547,6 +641,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -615,6 +718,52 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c20b425addb8661e97fe1d51c4d8bcec3ec29ed6ad0db983976a7521276b8f7" +dependencies = [ + "hashbrown 0.17.0", + "linebender_resource_handle", + "memmap2", + "objc2", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation", + "parlance", + "read-fonts 0.39.2", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -721,9 +870,9 @@ dependencies = [ [[package]] name = "ggsql" -version = "0.3.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a20e42aa4b984d0288a198441d345d51c4bc71a46f27aec68fd00c72ab5b119" +checksum = "3c535e6c8cbf264bb23ffac458ddc2837ffeb502a8c52c9885ec4d71d008467b" dependencies = [ "arrow", "bytes", @@ -731,6 +880,8 @@ dependencies = [ "const_format", "csscolorparser", "duckdb", + "fontique", + "hephaestus", "palette", "rand 0.8.6", "regex", @@ -745,7 +896,7 @@ dependencies = [ [[package]] name = "ggsql-python" -version = "0.3.3" +version = "0.5.2" dependencies = [ "arrow", "ggsql", @@ -764,6 +915,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551ed25397e4b444e89686602877d5cf3a7f6e3d548dcac37a8357d1e195f4df" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "read-fonts 0.39.2", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -779,20 +943,17 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -809,6 +970,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hephaestus" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755ec8c5f3f62917c498d1fdbc6704dad9432c5f523696337895601342aa8c43" +dependencies = [ + "clipper2-rust", + "flate2", + "kurbo", + "parley", + "peniko", + "pulldown-cmark", + "skrifa 0.44.0", + "thiserror 2.0.18", +] + [[package]] name = "http" version = "1.4.0" @@ -945,6 +1122,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.2.0" @@ -953,11 +1145,18 @@ checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + [[package]] name = "icu_normalizer" version = "2.2.0" @@ -1006,6 +1205,8 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1013,6 +1214,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + [[package]] name = "id-arena" version = "2.3.0" @@ -1120,6 +1342,18 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1191,9 +1425,9 @@ checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libduckdb-sys" -version = "1.4.4" +version = "1.10502.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78bacb8933586cee3b550c39b610d314f9b7a48701ac7a914a046165a4ad8da" +checksum = "8d7401630ae2abcff642f7156294289e50f2d222e061c026ad797b01bf20c215" dependencies = [ "cc", "flate2", @@ -1206,6 +1440,16 @@ dependencies = [ "zip", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1221,9 +1465,21 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.4", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1236,6 +1492,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1254,6 +1519,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1358,6 +1632,50 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1387,6 +1705,74 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parlance" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" + +[[package]] +name = "parley" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fad031076f48f0d4d85ce1aea9b94b4e715a4d636a030a123038f8f5b5e4343" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.0", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa 0.42.1", +] + +[[package]] +name = "parley_data" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ab9ace3fad1b9ed603ddac5b595e69931fc50263d7e04e4055015b77b02da5" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1456,6 +1842,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1468,6 +1863,8 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -1528,6 +1925,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "pyo3" version = "0.26.0" @@ -1730,6 +2138,36 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.6", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.7.4" @@ -1860,6 +2298,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rust_decimal" version = "1.41.0" @@ -1892,6 +2339,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1901,7 +2361,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1952,6 +2412,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "seahash" version = "4.1.0" @@ -2044,6 +2510,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -2155,6 +2641,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2254,6 +2751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -2406,9 +2904,9 @@ dependencies = [ [[package]] name = "tree-sitter-ggsql" -version = "0.3.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7e9e3a3ef7f12f580388bdd039be20e96b1066592c6c35795e866a6da09b5" +checksum = "ea99c672901d7873273d3f2bc114f2de85dcf602408639879555124470ac17d4" dependencies = [ "cc", "tree-sitter", @@ -2435,6 +2933,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2658,6 +3162,49 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2671,6 +3218,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -2699,6 +3257,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2777,6 +3345,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2998,7 +3575,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", +] + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", ] [[package]] @@ -3080,6 +3668,7 @@ dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -3088,6 +3677,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index ad3f5b0..47a723c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "ggsql-python" -version = "0.3.3" +version = "0.5.2" edition = "2021" authors = ["ggsql Team"] license = "MIT" repository = "https://github.com/posit-dev/ggsql-python" homepage = "https://ggsql.org" description = "Python bindings for ggsql" +rust-version = "1.86" [lib] name = "_ggsql" @@ -14,8 +15,8 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.26", features = ["extension-module", "abi3-py310"] } -arrow = { version = "56", default-features = false, features = ["ipc"] } -ggsql = { version = "0.3.3", default-features = false, features = ["duckdb", "vegalite"] } +arrow = { version = "58", default-features = false, features = ["ipc"] } +ggsql = { version = "0.5.2", default-features = false, features = ["duckdb", "vegalite", "builtin-data", "svg", "pdf", "hep"] } [features] default = [] diff --git a/pyproject.toml b/pyproject.toml index ac85367..0f8b3b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ggsql" -version = "0.3.3" +version = "0.5.2" description = "SQL extension for declarative data visualization" readme = "README.md" requires-python = ">=3.10" diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index adee744..b4b52ce 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -33,7 +33,7 @@ "execute", "render_altair", ] -__version__ = "0.3.0" +__version__ = "0.5.2" # Type alias for any Altair chart type AltairChart = Union[ diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 2691d74..edfedc1 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -59,14 +59,15 @@ def test_invalid_query_has_errors(self): assert len(validated.errors()) > 0 def test_missing_required_aesthetic(self): - # Point requires x and y, only providing x + # Text requires label, not providing it + # (point's position aesthetics became optional in ggsql 0.4.0) validated = ggsql.validate( - "SELECT 1 AS x, 2 AS y VISUALISE DRAW point MAPPING x AS x" + "SELECT 1 AS x, 2 AS y VISUALISE DRAW text MAPPING x AS x" ) assert not validated.valid() errors = validated.errors() assert len(errors) > 0 - assert any("y" in e["message"] for e in errors) + assert any("label" in e["message"] for e in errors) class TestDuckDBReader: diff --git a/uv.lock b/uv.lock index 9f54f02..81d68e3 100644 --- a/uv.lock +++ b/uv.lock @@ -529,7 +529,7 @@ wheels = [ [[package]] name = "ggsql" -version = "0.3.3" +version = "0.5.2" source = { editable = "." } dependencies = [ { name = "altair" }, From e0b12ad31ed639fea3c9563ee8f4a1d56f7b28c4 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 8 Oct 2026 17:02:14 -0500 Subject: [PATCH 02/17] feat: add SVGWriter for native SVG rendering --- python/ggsql/__init__.py | 46 +++++++++++++++++++++++++- src/lib.rs | 71 +++++++++++++++++++++++++++++++++++++++- tests/test_ggsql.py | 20 +++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index b4b52ce..72881c5 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import warnings from typing import Any, Union import altair @@ -9,6 +10,7 @@ from ggsql._ggsql import ( DuckDBReader, + SVGWriter as _RustSVGWriter, VegaLiteWriter as _RustVegaLiteWriter, Validated, Spec, @@ -18,13 +20,14 @@ # PyO3 classes default to __module__ = "builtins"; point them at their real # home so docs tooling (great-docs/griffe) can locate them. -for _cls in (DuckDBReader, _RustVegaLiteWriter, Validated, Spec): +for _cls in (DuckDBReader, _RustSVGWriter, _RustVegaLiteWriter, Validated, Spec): _cls.__module__ = "ggsql._ggsql" del _cls __all__ = [ # Classes "DuckDBReader", + "SVGWriter", "VegaLiteWriter", "Validated", "Spec", @@ -47,6 +50,15 @@ ] +def _warn_render_issues(issues: list[str]) -> None: + """Surface writer render warnings (from Rust's render_reporting) to the user.""" + if not issues: + return + message = f"ggsql writer reported {len(issues)} issue(s) while rendering:" + message += "\n" + "\n".join(f" - {issue}" for issue in issues) + warnings.warn(message, UserWarning, stacklevel=3) + + def _json_to_altair_chart(vegalite_json: str, **kwargs: Any) -> AltairChart: """Convert a Vega-Lite JSON string to the appropriate Altair chart type.""" kwargs.setdefault("validate", False) @@ -75,6 +87,38 @@ def _json_to_altair_chart(vegalite_json: str, **kwargs: Any) -> AltairChart: return altair.Chart.from_json(vegalite_json, **kwargs) +class SVGWriter: + """SVG output writer using ggsql's native renderer. + + Parameters + ---------- + width + Canvas width in pixels. + height + Canvas height in pixels. + dpi + Canvas resolution in dots per inch. + + Methods + ------- + render(spec) + Render a Spec to an SVG string. + """ + + def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: + self._inner = _RustSVGWriter(width, height, dpi) + + def render(self, spec: Spec) -> str: + """Render a Spec to an SVG string. + + Render warnings reported by the writer are surfaced via + ``warnings.warn()``. + """ + output, issues = self._inner.render(spec) + _warn_render_issues(issues) + return output + + class VegaLiteWriter: """Vega-Lite v6 JSON output writer. diff --git a/src/lib.rs b/src/lib.rs index 822c2d3..3ab1133 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,9 @@ use ggsql::dataframe::DataFrame; use ggsql::reader::Spec; use ggsql::reader::{DuckDBReader as RustDuckDBReader, Reader}; use ggsql::validate::{validate as rust_validate, ValidationWarning}; -use ggsql::writer::{VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter}; +use ggsql::writer::{ + SvgWriter as RustSvgWriter, VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter, +}; use ggsql::GgsqlError; // ============================================================================ @@ -442,6 +444,72 @@ impl PyVegaLiteWriter { } } +// ============================================================================ +// PySVGWriter +// ============================================================================ + +/// SVG output writer (native hephaestus renderer). +/// +/// Renders visualization specifications to SVG markup without any +/// JavaScript round-trip. +/// +/// Examples +/// -------- +/// >>> writer = SVGWriter(width=600, height=400, dpi=96) +/// >>> spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") +/// >>> svg = writer.render(spec) +#[pyclass(name = "SVGWriter")] +struct PySVGWriter { + inner: RustSvgWriter, +} + +#[pymethods] +impl PySVGWriter { + /// Create a new SVG writer. + /// + /// Parameters + /// ---------- + /// width : int + /// Canvas width in pixels. + /// height : int + /// Canvas height in pixels. + /// dpi : float + /// Canvas resolution in dots per inch. + /// + /// Returns + /// ------- + /// SVGWriter + /// A configured SVG writer instance. + #[new] + fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + inner: RustSvgWriter::new(width.max(1), height.max(1), dpi), + } + } + + /// Render a Spec to SVG markup. + /// + /// Parameters + /// ---------- + /// spec : Spec + /// The visualization specification from reader.execute(). + /// + /// Returns + /// ------- + /// tuple[str, list[str]] + /// The SVG markup and a list of render warnings (usually empty). + /// + /// Raises + /// ------ + /// ValueError + /// If rendering fails. + fn render(&self, spec: &PySpec) -> PyResult<(String, Vec)> { + self.inner + .render_reporting(&spec.inner) + .map_err(|e| PyErr::new::(e.to_string())) + } +} + // ============================================================================ // PyValidated // ============================================================================ @@ -795,6 +863,7 @@ fn execute(query: &str, reader: &Bound<'_, PyAny>) -> PyResult { fn _ggsql(m: &Bound<'_, PyModule>) -> PyResult<()> { // Classes m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index edfedc1..77ac147 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -704,3 +704,23 @@ def test_render_chart_default_preserves_ggsql_dimensions(self): assert chart_dict["height"] == raw_spec["height"] if "width" in raw_spec: assert chart_dict["width"] == raw_spec["width"] + + +class TestSVGWriter: + def test_create_writer(self): + writer = ggsql.SVGWriter() + assert writer is not None + + def test_render_to_svg(self): + reader = ggsql.DuckDBReader("duckdb://memory") + spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") + svg = ggsql.SVGWriter().render(spec) + assert isinstance(svg, str) + assert " Date: Thu, 8 Oct 2026 17:02:52 -0500 Subject: [PATCH 03/17] feat: add PDFWriter and HepWriter for native rendering --- python/ggsql/__init__.py | 72 ++++++++++++++++++++- src/lib.rs | 134 ++++++++++++++++++++++++++++++++++++++- tests/test_ggsql.py | 33 ++++++++++ 3 files changed, 237 insertions(+), 2 deletions(-) diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index 72881c5..aa9f6bd 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -10,6 +10,8 @@ from ggsql._ggsql import ( DuckDBReader, + HepWriter as _RustHepWriter, + PDFWriter as _RustPDFWriter, SVGWriter as _RustSVGWriter, VegaLiteWriter as _RustVegaLiteWriter, Validated, @@ -20,7 +22,15 @@ # PyO3 classes default to __module__ = "builtins"; point them at their real # home so docs tooling (great-docs/griffe) can locate them. -for _cls in (DuckDBReader, _RustSVGWriter, _RustVegaLiteWriter, Validated, Spec): +for _cls in ( + DuckDBReader, + _RustHepWriter, + _RustPDFWriter, + _RustSVGWriter, + _RustVegaLiteWriter, + Validated, + Spec, +): _cls.__module__ = "ggsql._ggsql" del _cls @@ -28,6 +38,8 @@ # Classes "DuckDBReader", "SVGWriter", + "PDFWriter", + "HepWriter", "VegaLiteWriter", "Validated", "Spec", @@ -119,6 +131,64 @@ def render(self, spec: Spec) -> str: return output +class PDFWriter: + """PDF output writer using ggsql's native renderer. + + Parameters + ---------- + width + Canvas width in pixels. + height + Canvas height in pixels. + dpi + Canvas resolution in dots per inch. + + Methods + ------- + render(spec) + Render a Spec to a PDF document (bytes). + """ + + def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: + self._inner = _RustPDFWriter(width, height, dpi) + + def render(self, spec: Spec) -> bytes: + """Render a Spec to a PDF document as bytes. + + Render warnings reported by the writer are surfaced via + ``warnings.warn()``. + """ + output, issues = self._inner.render(spec) + _warn_render_issues(issues) + return output + + +class HepWriter: + """.hep plot document writer using ggsql's native renderer. + + A `.hep` document is a self-contained description of the resolved plot + that a compatible consumer can render and re-layout at any size. + + Methods + ------- + render(spec) + Render a Spec to a .hep plot document (bytes). + """ + + def __init__(self) -> None: + self._inner = _RustHepWriter() + + def render(self, spec: Spec) -> bytes: + """Render a Spec to a .hep plot document as bytes. + + Render warnings reported by the writer are surfaced via + ``warnings.warn()``. + """ + output, issues = self._inner.render(spec) + _warn_render_issues(issues) + return output + + class VegaLiteWriter: """Vega-Lite v6 JSON output writer. diff --git a/src/lib.rs b/src/lib.rs index 3ab1133..3be7f09 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,8 @@ use ggsql::reader::Spec; use ggsql::reader::{DuckDBReader as RustDuckDBReader, Reader}; use ggsql::validate::{validate as rust_validate, ValidationWarning}; use ggsql::writer::{ - SvgWriter as RustSvgWriter, VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter, + HepWriter as RustHepWriter, PdfWriter as RustPdfWriter, SvgWriter as RustSvgWriter, + VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter, }; use ggsql::GgsqlError; @@ -510,6 +511,135 @@ impl PySVGWriter { } } +// ============================================================================ +// PyPDFWriter +// ============================================================================ + +/// PDF output writer (native hephaestus renderer). +/// +/// Renders visualization specifications to PDF documents. +/// +/// Examples +/// -------- +/// >>> writer = PDFWriter(width=600, height=400, dpi=96) +/// >>> spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") +/// >>> pdf_bytes = writer.render(spec) +#[pyclass(name = "PDFWriter")] +struct PyPDFWriter { + inner: RustPdfWriter, +} + +#[pymethods] +impl PyPDFWriter { + /// Create a new PDF writer. + /// + /// Parameters + /// ---------- + /// width : int + /// Canvas width in pixels. + /// height : int + /// Canvas height in pixels. + /// dpi : float + /// Canvas resolution in dots per inch. + /// + /// Returns + /// ------- + /// PDFWriter + /// A configured PDF writer instance. + #[new] + fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + inner: RustPdfWriter::new(width.max(1), height.max(1), dpi), + } + } + + /// Render a Spec to a PDF document. + /// + /// Parameters + /// ---------- + /// spec : Spec + /// The visualization specification from reader.execute(). + /// + /// Returns + /// ------- + /// tuple[bytes, list[str]] + /// The PDF document as bytes and a list of render warnings + /// (usually empty). + /// + /// Raises + /// ------ + /// ValueError + /// If rendering fails. + fn render(&self, py: Python<'_>, spec: &PySpec) -> PyResult<(Py, Vec)> { + let (bytes, warnings) = self + .inner + .render_reporting(&spec.inner) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok((PyBytes::new(py, &bytes).unbind(), warnings)) + } +} + +// ============================================================================ +// PyHepWriter +// ============================================================================ + +/// .hep plot document writer (native hephaestus renderer). +/// +/// Renders visualization specifications to `.hep` plot documents — a +/// self-contained description of the resolved plot that a compatible +/// consumer can render and re-layout at any size. +/// +/// Examples +/// -------- +/// >>> writer = HepWriter() +/// >>> spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") +/// >>> doc_bytes = writer.render(spec) +#[pyclass(name = "HepWriter")] +struct PyHepWriter { + inner: RustHepWriter, +} + +#[pymethods] +impl PyHepWriter { + /// Create a new .hep writer. + /// + /// Returns + /// ------- + /// HepWriter + /// A configured .hep writer instance. + #[new] + fn new() -> Self { + Self { + inner: RustHepWriter::default(), + } + } + + /// Render a Spec to a .hep plot document. + /// + /// Parameters + /// ---------- + /// spec : Spec + /// The visualization specification from reader.execute(). + /// + /// Returns + /// ------- + /// tuple[bytes, list[str]] + /// The .hep document as bytes and a list of render warnings + /// (usually empty). + /// + /// Raises + /// ------ + /// ValueError + /// If rendering fails. + fn render(&self, py: Python<'_>, spec: &PySpec) -> PyResult<(Py, Vec)> { + let (bytes, warnings) = self + .inner + .render_reporting(&spec.inner) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok((PyBytes::new(py, &bytes).unbind(), warnings)) + } +} + // ============================================================================ // PyValidated // ============================================================================ @@ -864,6 +994,8 @@ fn _ggsql(m: &Bound<'_, PyModule>) -> PyResult<()> { // Classes m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 77ac147..31a86e2 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -724,3 +724,36 @@ def test_render_with_dimensions(self): svg = ggsql.SVGWriter(width=400, height=300, dpi=96).render(spec) assert isinstance(svg, str) assert " 0 From 16616ed4ddf0c0dcfa70914617dd16bc38a7f0f5 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 8 Oct 2026 17:03:14 -0500 Subject: [PATCH 04/17] feat: add ggsql.save() for exporting specs to svg/pdf/hep/json --- python/ggsql/__init__.py | 57 ++++++++++++++++++++++++++++++++++++++++ tests/test_ggsql.py | 37 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index aa9f6bd..f5d25d3 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -2,6 +2,7 @@ import json import warnings +from pathlib import Path from typing import Any, Union import altair @@ -47,6 +48,7 @@ "validate", "execute", "render_altair", + "save", ] __version__ = "0.5.2" @@ -249,6 +251,61 @@ def render_chart( return _json_to_altair_chart(vegalite_json, validate=validate, **kwargs) +def save( + spec: Spec, + file: str | Path, + *, + width: int = 600, + height: int = 400, + dpi: float = 96.0, +) -> Path: + """Save a ggsql spec to a file. + + The format is derived from the file extension: `.svg`, `.pdf`, and + `.hep` are written by the corresponding native writer, and `.json` + writes the Vega-Lite representation. + + Parameters + ---------- + spec + The resolved visualization specification from ``reader.execute()``. + file + Output file path. Extension determines format: `.svg`, `.pdf`, + `.hep`, or `.json`. + width + Canvas width in pixels (ignored for `.hep` and `.json`). + height + Canvas height in pixels (ignored for `.hep` and `.json`). + dpi + Canvas resolution in dots per inch (ignored for `.hep` and `.json`). + + Returns + ------- + Path + The path that was written. + + Raises + ------ + ValueError + If the file extension is not supported. + """ + path = Path(file) + ext = path.suffix.lower() + if ext == ".svg": + path.write_text(SVGWriter(width, height, dpi).render(spec), encoding="utf-8") + elif ext == ".pdf": + path.write_bytes(PDFWriter(width, height, dpi).render(spec)) + elif ext == ".hep": + path.write_bytes(HepWriter().render(spec)) + elif ext == ".json": + path.write_text(VegaLiteWriter().render(spec), encoding="utf-8") + else: + raise ValueError( + f"Unsupported file extension {ext!r}. Use .svg, .pdf, .hep, or .json." + ) + return path + + def render_altair( df: IntoFrame, viz: str, diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 31a86e2..c9f8f61 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -757,3 +757,40 @@ def test_render_to_hep(self): doc = ggsql.HepWriter().render(spec) assert isinstance(doc, bytes) assert len(doc) > 0 + + +class TestSave: + @pytest.fixture + def spec(self): + reader = ggsql.DuckDBReader("duckdb://memory") + return reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") + + def test_save_svg(self, spec, tmp_path): + out = tmp_path / "plot.svg" + ggsql.save(spec, out) + assert " 0 + + def test_save_json(self, spec, tmp_path): + out = tmp_path / "plot.json" + ggsql.save(spec, out) + spec_dict = json.loads(out.read_text()) + assert "$schema" in spec_dict or "mark" in spec_dict + + def test_save_with_dimensions(self, spec, tmp_path): + out = tmp_path / "plot.svg" + ggsql.save(spec, out, width=400, height=300, dpi=96) + assert " Date: Thu, 8 Oct 2026 17:03:33 -0500 Subject: [PATCH 05/17] docs: document native writers and ggsql.save() --- CHANGELOG.md | 10 +++++++++- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3616c4f..703dbaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ # Changelog -## UNRELEASED +## 0.5.2 + +### Added + +- New native writers `SVGWriter`, `PDFWriter`, and `HepWriter`, rendered directly by ggsql (via hephaestus) with no JavaScript round-trip. Each exposes `render(spec)` returning `str` (SVG) or `bytes` (PDF/HEP); render warnings are surfaced via `warnings.warn()`. +- New `ggsql.save(spec, file, ...)` helper that writes a spec to disk, picking the writer from the file extension (`.svg`, `.pdf`, `.hep`, or `.json` for Vega-Lite). +- Enabled the crate's `builtin-data` feature, making builtin datasets such as `ggsql:world` available. ### Changed +- Upgraded to ggsql Rust crate v0.5.2 (from v0.3.3). See the [crate changelog](https://github.com/posit-dev/ggsql/blob/main/CHANGELOG.md) for the many language and rendering improvements in 0.4.0–0.5.2. +- Upgraded the Arrow dependency from v56 to v58. - `VegaLiteWriter.render_chart()` and `render_altair()` now accept `validate`, `height`, and `width` as explicit keyword arguments. `height` and `width` default to `None`, which preserves whatever dimensions ggsql produces. `validate` defaults to `False` (same behavior as before, now a named parameter). ## 0.3.2 diff --git a/README.md b/README.md index 81782a5..1142b2e 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,29 @@ vegalite_json = writer.render(spec) print(vegalite_json) ``` +### Native Writers (SVG, PDF, HEP) + +In addition to Vega-Lite/Altair output, ggsql can render plots natively to +SVG, PDF, and `.hep` plot documents — no JavaScript required: + +```python +import ggsql + +reader = ggsql.DuckDBReader("duckdb://memory") +spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") + +# Render with a specific writer +svg = ggsql.SVGWriter(width=600, height=400, dpi=96).render(spec) # str +pdf = ggsql.PDFWriter(width=600, height=400, dpi=96).render(spec) # bytes +doc = ggsql.HepWriter().render(spec) # bytes + +# Or save directly — the format is picked from the file extension +ggsql.save(spec, "plot.svg") +ggsql.save(spec, "plot.pdf") +ggsql.save(spec, "plot.hep") +ggsql.save(spec, "plot.json") # Vega-Lite JSON +``` + ## API Reference ### Classes @@ -138,6 +161,25 @@ writer = ggsql.VegaLiteWriter() json_output = writer.render(spec) ``` +#### `SVGWriter(width: int = 600, height: int = 400, dpi: float = 96.0)` + +Renders specs to SVG markup using ggsql's native renderer. + +- `render(spec: Spec) -> str` — Render to an SVG string. + +#### `PDFWriter(width: int = 600, height: int = 400, dpi: float = 96.0)` + +Renders specs to PDF documents using ggsql's native renderer. + +- `render(spec: Spec) -> bytes` — Render to a PDF document. + +#### `HepWriter()` + +Renders specs to `.hep` plot documents (self-contained plot descriptions a +compatible consumer can re-layout at any size) using ggsql's native renderer. + +- `render(spec: Spec) -> bytes` — Render to a `.hep` document. + #### `Validated` Result of `validate()` containing query analysis without SQL execution. @@ -212,6 +254,11 @@ table = pa.table({"x": [1, 2, 3], "y": [10, 20, 30]}) chart = ggsql.render_altair(table, "VISUALISE x, y DRAW point") ``` +#### `save(spec, file, *, width=600, height=400, dpi=96) -> Path` + +Save a spec to a file. The extension picks the format: `.svg`, `.pdf`, +`.hep` (native writers) or `.json` (Vega-Lite). + ## Examples ### Mapping Styles From 180eee290736b379840d07e900b4b39fedadd99a Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 9 Oct 2026 09:30:53 -0500 Subject: [PATCH 06/17] docs: document py/v* release tag workflow --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7485e21..41554a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,10 @@ To pick up a new version of the upstream `ggsql` Rust crate, bump its version in **Key design pattern**: Two-stage API — `reader.execute(query) -> Spec`, then `writer.render(spec) -> str` or `writer.render_chart(spec) -> AltairChart`. The `render_altair()` shortcut collapses both stages. +## Releasing + +The Python Release workflow (`.github/workflows/release-python.yml`) triggers on tags matching `py/v*`. When creating a GitHub release, use `gh release create py/vX.Y.Z` — a plain `vX.Y.Z` tag won't trigger the build/publish pipeline. + ## `.cargo/config.toml` Sets `GGSQL_SKIP_GENERATE=1` so tree-sitter uses its pre-generated parser rather than regenerating from `grammar.js`. Don't remove this. From 71892c674d6118e1642ce77cba69f356d31af231 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 9 Oct 2026 10:44:42 -0500 Subject: [PATCH 07/17] fix: address Copilot review findings - Bump declared Rust MSRV to 1.88: the native-writer dependency graph (parley 0.9) requires it, so 1.86 was not actually supported. - Validate dpi in SVGWriter/PDFWriter constructors (rejects non-positive and non-finite values) instead of letting them reach the renderer. - Add Python integration tests for builtin datasets (ggsql:penguins, ggsql:airquality) and dpi validation. --- CHANGELOG.md | 1 + Cargo.toml | 2 +- python/ggsql/__init__.py | 9 +++++++++ tests/test_ggsql.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 703dbaf..4731428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Upgraded to ggsql Rust crate v0.5.2 (from v0.3.3). See the [crate changelog](https://github.com/posit-dev/ggsql/blob/main/CHANGELOG.md) for the many language and rendering improvements in 0.4.0–0.5.2. - Upgraded the Arrow dependency from v56 to v58. +- Declared Rust MSRV is now 1.88 (required by the native-writer dependency graph, e.g. parley 0.9). - `VegaLiteWriter.render_chart()` and `render_altair()` now accept `validate`, `height`, and `width` as explicit keyword arguments. `height` and `width` default to `None`, which preserves whatever dimensions ggsql produces. `validate` defaults to `False` (same behavior as before, now a named parameter). ## 0.3.2 diff --git a/Cargo.toml b/Cargo.toml index 47a723c..04181c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" repository = "https://github.com/posit-dev/ggsql-python" homepage = "https://ggsql.org" description = "Python bindings for ggsql" -rust-version = "1.86" +rust-version = "1.88" [lib] name = "_ggsql" diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index f5d25d3..a88bb90 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math import warnings from pathlib import Path from typing import Any, Union @@ -101,6 +102,12 @@ def _json_to_altair_chart(vegalite_json: str, **kwargs: Any) -> AltairChart: return altair.Chart.from_json(vegalite_json, **kwargs) +def _validate_dpi(dpi: float) -> None: + """Reject non-positive or non-finite DPI before reaching the renderer.""" + if not math.isfinite(dpi) or dpi <= 0: + raise ValueError(f"dpi must be a positive, finite number, got {dpi!r}") + + class SVGWriter: """SVG output writer using ggsql's native renderer. @@ -120,6 +127,7 @@ class SVGWriter: """ def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: + _validate_dpi(dpi) self._inner = _RustSVGWriter(width, height, dpi) def render(self, spec: Spec) -> str: @@ -152,6 +160,7 @@ class PDFWriter: """ def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: + _validate_dpi(dpi) self._inner = _RustPDFWriter(width, height, dpi) def render(self, spec: Spec) -> bytes: diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index c9f8f61..a2db7bf 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -794,3 +794,39 @@ def test_save_with_dimensions(self, spec, tmp_path): def test_save_unknown_extension_raises(self, spec, tmp_path): with pytest.raises(ValueError, match="Unsupported file extension"): ggsql.save(spec, tmp_path / "plot.png") + + def test_save_invalid_dpi_raises(self, spec, tmp_path): + with pytest.raises(ValueError, match="dpi must be a positive"): + ggsql.save(spec, tmp_path / "plot.svg", dpi=0) + + +class TestWriterDpiValidation: + @pytest.mark.parametrize("bad_dpi", [0, -1, -96.0, float("nan"), float("inf")]) + def test_svg_writer_rejects_invalid_dpi(self, bad_dpi): + with pytest.raises(ValueError, match="dpi must be a positive"): + ggsql.SVGWriter(dpi=bad_dpi) + + @pytest.mark.parametrize("bad_dpi", [0, -1, -96.0, float("nan"), float("inf")]) + def test_pdf_writer_rejects_invalid_dpi(self, bad_dpi): + with pytest.raises(ValueError, match="dpi must be a positive"): + ggsql.PDFWriter(dpi=bad_dpi) + + +class TestBuiltinData: + """Integration coverage for builtin datasets (ggsql:... relations).""" + + def test_execute_builtin_dataset(self): + reader = ggsql.DuckDBReader("duckdb://memory") + spec = reader.execute( + "SELECT * FROM ggsql:penguins " + "VISUALISE DRAW point MAPPING bill_len AS x, bill_dep AS y" + ) + assert spec is not None + + def test_builtin_dataset_renders_svg(self): + reader = ggsql.DuckDBReader("duckdb://memory") + spec = reader.execute( + "VISUALISE FROM ggsql:airquality DRAW point MAPPING Temp AS x, Ozone AS y" + ) + svg = ggsql.SVGWriter().render(spec) + assert " Date: Mon, 12 Oct 2026 10:53:55 -0500 Subject: [PATCH 08/17] ci: pin Windows jobs to windows-2022 windows-latest now resolves to a Windows Server 2025 image with the VS2026/v145 toolchain, whose STL removed stdext::checked_array_iterator. ggsql 0.5.2 pins duckdb ~1.10502, and duckdb 1.5.2's vendored fmt still uses that symbol under #ifdef _SECURE_SCL, so the bundled duckdb build hard-fails on windows-latest (duckdb/duckdb-rs#783, fixed upstream in libduckdb-sys 1.10506). windows-2022's VS2022/v143 STL still has the (deprecated) symbol, and upstream ggsql pins windows-2022 for its own release builds for the same reason. This is a stopgap: revert to windows-latest once ggsql releases with duckdb ~1.10506. --- .github/workflows/python.yml | 13 +++++++++++-- .github/workflows/release-python.yml | 6 +++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 752c0b8..3f63520 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -11,14 +11,23 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # NOTE: windows is pinned to windows-2022 (not windows-latest). + # ggsql 0.5.2 pins duckdb ~1.10502, whose bundled build uses + # stdext::checked_array_iterator (via vendored fmt). That symbol is + # deprecated but present in windows-2022's VS2022/v143 STL, and fully + # removed in the VS2026/v145 STL that windows-latest now provides, so + # the duckdb build hard-fails there (duckdb/duckdb-rs#783, fixed in + # libduckdb-sys 1.10506). Upstream ggsql pins windows-2022 for the + # same reason. Revert to windows-latest once we depend on a ggsql + # release with duckdb ~1.10506. + os: [ubuntu-latest, macos-latest, windows-2022] python: ["3.10", "3.11", "3.12", "3.13"] include: - os: ubuntu-latest delay: 140 - os: macos-latest delay: 210 - - os: windows-latest + - os: windows-2022 delay: 70 runs-on: ${{ matrix.os }} steps: diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 68c51d6..116f30b 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -70,8 +70,12 @@ jobs: name: wheels-macos-${{ matrix.target }} path: dist + # Pinned to windows-2022: bundled duckdb (via ggsql 0.5.2's duckdb + # ~1.10502 pin) does not compile on windows-latest's VS2026/v145 STL. + # See the matching note in python.yml. Revert once ggsql depends on + # duckdb ~1.10506. windows: - runs-on: windows-latest + runs-on: windows-2022 steps: - uses: actions/checkout@v4 From 1946a54a86e66042c8b17873ba52d4cdf30453e7 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 11:28:30 -0500 Subject: [PATCH 09/17] docs: add missing 0.3.3 changelog section and release checklist The 0.3.3 release went out with its changelog entries still under UNRELEASED (and the altair.Chart fallback entry misfiled under 0.3.2). Record them under a proper 0.3.3 section, and add a release checklist to CLAUDE.md so the changelog/version steps aren't skipped again. --- CHANGELOG.md | 8 +++++++- CLAUDE.md | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4731428..19c02aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,14 @@ - Upgraded to ggsql Rust crate v0.5.2 (from v0.3.3). See the [crate changelog](https://github.com/posit-dev/ggsql/blob/main/CHANGELOG.md) for the many language and rendering improvements in 0.4.0–0.5.2. - Upgraded the Arrow dependency from v56 to v58. - Declared Rust MSRV is now 1.88 (required by the native-writer dependency graph, e.g. parley 0.9). + +## 0.3.3 + +### Changed + +- Upgraded to ggsql Rust crate v0.3.3 (hotfix). - `VegaLiteWriter.render_chart()` and `render_altair()` now accept `validate`, `height`, and `width` as explicit keyword arguments. `height` and `width` default to `None`, which preserves whatever dimensions ggsql produces. `validate` defaults to `False` (same behavior as before, now a named parameter). +- When Altair can't deserialize a spec into the expected chart subclass (e.g., `LayerChart`), the converter now falls back to `altair.Chart` instead of raising. The chart still displays correctly; only Altair-level round-tripping (`.to_dict()`) is lost. ## 0.3.2 @@ -21,7 +28,6 @@ - Upgraded to ggsql Rust crate v0.3.2. - `render_altair()` and `VegaLiteWriter.render_chart()` now default to `validate=False` when creating Altair chart objects. This avoids `ValidationError`s for valid specs (e.g., boxplots) that use Vega-Lite features not yet reflected in Altair's schema. Pass `validate=True` to re-enable. -- When Altair can't deserialize a spec into the expected chart subclass (e.g., `LayerChart`), the converter now falls back to `altair.Chart` instead of raising. The chart still displays correctly; only Altair-level round-tripping (`.to_dict()`) is lost. ## 0.3.1 diff --git a/CLAUDE.md b/CLAUDE.md index 41554a8..adda96b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,12 @@ To pick up a new version of the upstream `ggsql` Rust crate, bump its version in The Python Release workflow (`.github/workflows/release-python.yml`) triggers on tags matching `py/v*`. When creating a GitHub release, use `gh release create py/vX.Y.Z` — a plain `vX.Y.Z` tag won't trigger the build/publish pipeline. +Release checklist (all in the bump commit, before tagging): + +1. Rename the `## UNRELEASED` section of `CHANGELOG.md` to the new version. +2. Bump `version` in `pyproject.toml` and `Cargo.toml`, then run `uv sync` to update `uv.lock` and `Cargo.lock`. (`ggsql.__version__` follows automatically via `importlib.metadata`.) +3. After the release workflow finishes, check that the GitHub release notes summarize the changelog section — edit with `gh release edit py/vX.Y.Z` if needed. + ## `.cargo/config.toml` Sets `GGSQL_SKIP_GENERATE=1` so tree-sitter uses its pre-generated parser rather than regenerating from `grammar.js`. Don't remove this. From dd962586759acb0f979c9c75767aee85177dcc94 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 11:28:30 -0500 Subject: [PATCH 10/17] fix: derive __version__ from package metadata; validate width/height ggsql.__version__ was hardcoded and drifted from pyproject (it read 0.3.0 in the published 0.3.1-0.3.3 wheels). Read it via importlib.metadata instead, with a test pinning it to pyproject.toml. Also reject non-positive width/height in SVGWriter/PDFWriter/save() with a clear ValueError, matching the existing dpi validation, instead of surfacing a raw PyO3 OverflowError (negative) or silently clamping (zero). --- python/ggsql/__init__.py | 18 +++++++++++++++++- tests/test_ggsql.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index a88bb90..640152e 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -3,6 +3,8 @@ import json import math import warnings +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _package_version from pathlib import Path from typing import Any, Union @@ -51,7 +53,11 @@ "render_altair", "save", ] -__version__ = "0.5.2" + +try: + __version__ = _package_version("ggsql") +except PackageNotFoundError: # not installed (e.g., running from a source tree) + __version__ = "0.0.0" # Type alias for any Altair chart type AltairChart = Union[ @@ -108,6 +114,12 @@ def _validate_dpi(dpi: float) -> None: raise ValueError(f"dpi must be a positive, finite number, got {dpi!r}") +def _validate_dimension(name: str, value: int) -> None: + """Reject non-positive canvas dimensions before reaching the renderer.""" + if value <= 0: + raise ValueError(f"{name} must be a positive integer, got {value!r}") + + class SVGWriter: """SVG output writer using ggsql's native renderer. @@ -127,6 +139,8 @@ class SVGWriter: """ def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: + _validate_dimension("width", width) + _validate_dimension("height", height) _validate_dpi(dpi) self._inner = _RustSVGWriter(width, height, dpi) @@ -160,6 +174,8 @@ class PDFWriter: """ def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: + _validate_dimension("width", width) + _validate_dimension("height", height) _validate_dpi(dpi) self._inner = _RustPDFWriter(width, height, dpi) diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index a2db7bf..7fcb2dd 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -9,6 +9,8 @@ """ import json +import re +from pathlib import Path import duckdb import pyarrow as pa @@ -800,6 +802,14 @@ def test_save_invalid_dpi_raises(self, spec, tmp_path): ggsql.save(spec, tmp_path / "plot.svg", dpi=0) +class TestVersion: + def test_version_matches_pyproject(self): + pyproject = Path(__file__).parent.parent / "pyproject.toml" + match = re.search(r'^version = "([^"]+)"', pyproject.read_text(), re.M) + assert match is not None + assert ggsql.__version__ == match.group(1) + + class TestWriterDpiValidation: @pytest.mark.parametrize("bad_dpi", [0, -1, -96.0, float("nan"), float("inf")]) def test_svg_writer_rejects_invalid_dpi(self, bad_dpi): @@ -812,6 +822,26 @@ def test_pdf_writer_rejects_invalid_dpi(self, bad_dpi): ggsql.PDFWriter(dpi=bad_dpi) +class TestWriterDimensionValidation: + @pytest.mark.parametrize("writer", [ggsql.SVGWriter, ggsql.PDFWriter]) + @pytest.mark.parametrize("bad_width", [0, -1, -600]) + def test_rejects_nonpositive_width(self, writer, bad_width): + with pytest.raises(ValueError, match="width must be a positive"): + writer(width=bad_width) + + @pytest.mark.parametrize("writer", [ggsql.SVGWriter, ggsql.PDFWriter]) + @pytest.mark.parametrize("bad_height", [0, -1, -400]) + def test_rejects_nonpositive_height(self, writer, bad_height): + with pytest.raises(ValueError, match="height must be a positive"): + writer(height=bad_height) + + def test_save_rejects_nonpositive_dimensions(self, tmp_path): + reader = ggsql.DuckDBReader("duckdb://memory") + spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") + with pytest.raises(ValueError, match="width must be a positive"): + ggsql.save(spec, tmp_path / "plot.svg", width=0) + + class TestBuiltinData: """Integration coverage for builtin datasets (ggsql:... relations).""" From 83f641726388637085a25b0f347494ec26263389 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 11:37:55 -0500 Subject: [PATCH 11/17] perf: release the GIL while native writers render SVG/PDF/HEP rendering is pure Rust work on immutable data, but it ran while holding the GIL, blocking other Python threads for the duration. Wrap the render calls in Python::detach so concurrent Python code (e.g. a web server or async event loop) keeps running during a slow render. Safety is compiler-checked: detach requires a Send closure, so this only builds because the writer and spec types are Sync. Single-threaded behavior is unchanged. --- src/lib.rs | 21 ++++++++++++--------- tests/test_ggsql.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3be7f09..2de8c59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -504,9 +504,12 @@ impl PySVGWriter { /// ------ /// ValueError /// If rendering fails. - fn render(&self, spec: &PySpec) -> PyResult<(String, Vec)> { - self.inner - .render_reporting(&spec.inner) + fn render(&self, py: Python<'_>, spec: &PySpec) -> PyResult<(String, Vec)> { + // Rendering is pure Rust work on immutable data, so release the GIL + // while it runs to avoid blocking other Python threads. This is + // compiler-checked: detach requires a Send closure, so this only + // builds if the writer and spec are safe to share. + py.detach(|| self.inner.render_reporting(&spec.inner)) .map_err(|e| PyErr::new::(e.to_string())) } } @@ -571,9 +574,9 @@ impl PyPDFWriter { /// ValueError /// If rendering fails. fn render(&self, py: Python<'_>, spec: &PySpec) -> PyResult<(Py, Vec)> { - let (bytes, warnings) = self - .inner - .render_reporting(&spec.inner) + // Release the GIL while rendering (see PySVGWriter::render). + let (bytes, warnings) = py + .detach(|| self.inner.render_reporting(&spec.inner)) .map_err(|e| PyErr::new::(e.to_string()))?; Ok((PyBytes::new(py, &bytes).unbind(), warnings)) } @@ -632,9 +635,9 @@ impl PyHepWriter { /// ValueError /// If rendering fails. fn render(&self, py: Python<'_>, spec: &PySpec) -> PyResult<(Py, Vec)> { - let (bytes, warnings) = self - .inner - .render_reporting(&spec.inner) + // Release the GIL while rendering (see PySVGWriter::render). + let (bytes, warnings) = py + .detach(|| self.inner.render_reporting(&spec.inner)) .map_err(|e| PyErr::new::(e.to_string()))?; Ok((PyBytes::new(py, &bytes).unbind(), warnings)) } diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 7fcb2dd..8a6e319 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -842,6 +842,24 @@ def test_save_rejects_nonpositive_dimensions(self, tmp_path): ggsql.save(spec, tmp_path / "plot.svg", width=0) +class TestWriterConcurrency: + """Native writers release the GIL while rendering; concurrent renders + of a shared writer and spec must produce identical, correct output.""" + + def test_concurrent_svg_renders(self): + from concurrent.futures import ThreadPoolExecutor + + reader = ggsql.DuckDBReader("duckdb://memory") + spec = reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") + writer = ggsql.SVGWriter() + expected = writer.render(spec) + + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(lambda _: writer.render(spec), range(8))) + + assert all(svg == expected for svg in results) + + class TestBuiltinData: """Integration coverage for builtin datasets (ggsql:... relations).""" From 97c5a28bbf5beb92fa619144f605e8fde3c1e04b Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 11:41:32 -0500 Subject: [PATCH 12/17] feat: expose the crate's writer options as **options on native writers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crate's native writers take more than width/height/dpi: units, background, and per-format settings (svg: text/embed-fonts/id-prefix; pdf: compress/links; hep: lossy/embed-fonts). Route constructor kwargs through the crate's WriterOptions/from_options so ggsql itself validates keys per writer — an unknown key or unusable value raises ValueError naming the option and listing the supported ones. Python booleans become "true"/"false"; underscore spellings match the crate's hyphenated keys via its key normalisation. This goes beyond the R bindings' surface (width/height/dpi only) by design: routing through from_options means options added upstream become available here without binding changes. HepWriter also gains optional width/height/dpi, recorded as consumer hints only when given (an optionless writer still records no hint). save() passes **options through to the native writer. --- CHANGELOG.md | 1 + README.md | 28 +++++++++-- python/ggsql/__init__.py | 106 ++++++++++++++++++++++++++++++++++----- src/lib.rs | 77 +++++++++++++++++++++++----- tests/test_ggsql.py | 50 ++++++++++++++++++ 5 files changed, 234 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c02aa..cee2237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - New native writers `SVGWriter`, `PDFWriter`, and `HepWriter`, rendered directly by ggsql (via hephaestus) with no JavaScript round-trip. Each exposes `render(spec)` returning `str` (SVG) or `bytes` (PDF/HEP); render warnings are surfaced via `warnings.warn()`. - New `ggsql.save(spec, file, ...)` helper that writes a spec to disk, picking the writer from the file extension (`.svg`, `.pdf`, `.hep`, or `.json` for Vega-Lite). +- The native writers (and `ggsql.save()`) accept the crate's additional writer options as keyword arguments — e.g. `background="transparent"`, `units="in"`, `text="outline"`, `embed_fonts=True`, `compress=False`. Unknown keys and invalid values raise `ValueError`. `HepWriter` can also record optional `width`/`height`/`dpi` hints for a consumer. - Enabled the crate's `builtin-data` feature, making builtin datasets such as `ggsql:world` available. ### Changed diff --git a/README.md b/README.md index 1142b2e..87e190b 100644 --- a/README.md +++ b/README.md @@ -161,25 +161,47 @@ writer = ggsql.VegaLiteWriter() json_output = writer.render(spec) ``` -#### `SVGWriter(width: int = 600, height: int = 400, dpi: float = 96.0)` +#### `SVGWriter(width: int = 600, height: int = 400, dpi: float = 96.0, **options)` Renders specs to SVG markup using ggsql's native renderer. - `render(spec: Spec) -> str` — Render to an SVG string. -#### `PDFWriter(width: int = 600, height: int = 400, dpi: float = 96.0)` +#### `PDFWriter(width: int = 600, height: int = 400, dpi: float = 96.0, **options)` Renders specs to PDF documents using ggsql's native renderer. - `render(spec: Spec) -> bytes` — Render to a PDF document. -#### `HepWriter()` +#### `HepWriter(width=None, height=None, dpi=None, **options)` Renders specs to `.hep` plot documents (self-contained plot descriptions a compatible consumer can re-layout at any size) using ggsql's native renderer. +The optional `width`/`height`/`dpi` are recorded as hints for the consumer, +not a fixed size; by default no hint is recorded. - `render(spec: Spec) -> bytes` — Render to a `.hep` document. +All three native writers accept extra keyword options, validated by ggsql +(unknown keys and bad values raise `ValueError`): + +- `units`: `"px"` (default), `"in"`, `"cm"`, `"mm"`, or `"pt"` — how + `width`/`height` are read. `SVGWriter(width=6, height=4, units="in", + dpi=300)` produces a 1800×1200 SVG that prints six inches wide. +- `background`: any CSS color; `"transparent"` emits no background + (default is white). +- SVG only: `text` (`"text"` or `"outline"` — draw glyphs as paths for a + self-contained file), `embed_fonts` (inline font files), `id_prefix` + (avoid id collisions when inlining several SVGs into one page). +- PDF only: `compress` (default `True`), `links` (default `True`). +- HEP only: `lossy` (drop what the format can't carry instead of failing), + `embed_fonts`. + +```python +svg = ggsql.SVGWriter(background="transparent").render(spec) +pdf = ggsql.PDFWriter(width=6, height=4, units="in", dpi=300).render(spec) +``` + #### `Validated` Result of `validate()` containing query analysis without SQL execution. diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index 640152e..c141cfb 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -120,17 +120,46 @@ def _validate_dimension(name: str, value: int) -> None: raise ValueError(f"{name} must be a positive integer, got {value!r}") +def _stringify_options(options: dict[str, Any]) -> list[tuple[str, str]]: + """Convert writer option values to the strings the crate expects. + + Keys pass through unchanged: the crate normalises them (case- and + separator-insensitive), so ``embed_fonts=True`` matches the crate's + ``embed-fonts`` spelling. Booleans become ``"true"``/``"false"``. + """ + pairs = [] + for key, value in options.items(): + if isinstance(value, bool): + value = "true" if value else "false" + pairs.append((key, str(value))) + return pairs + + class SVGWriter: """SVG output writer using ggsql's native renderer. Parameters ---------- width - Canvas width in pixels. + Canvas width in pixels (or in `units` if given). height - Canvas height in pixels. + Canvas height in pixels (or in `units` if given). dpi Canvas resolution in dots per inch. + **options + Additional writer options, validated by ggsql (an unknown key or + bad value raises ``ValueError``): + + - ``units``: ``"px"`` (default), ``"in"``, ``"cm"``, ``"mm"``, or + ``"pt"`` — how `width`/`height` are read. + - ``background``: any CSS color; ``"transparent"`` emits no + background (default is white). + - ``text``: ``"text"`` (default, real selectable text) or + ``"outline"`` (glyphs as paths, self-contained). + - ``embed_fonts``: inline font files so the SVG renders identically + anywhere (default ``False``; can make files much larger). + - ``id_prefix``: prefix for generated element ids (avoids id + collisions when inlining multiple SVGs into one page). Methods ------- @@ -138,11 +167,13 @@ class SVGWriter: Render a Spec to an SVG string. """ - def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: + def __init__( + self, width: int = 600, height: int = 400, dpi: float = 96.0, **options: Any + ) -> None: _validate_dimension("width", width) _validate_dimension("height", height) _validate_dpi(dpi) - self._inner = _RustSVGWriter(width, height, dpi) + self._inner = _RustSVGWriter(width, height, dpi, _stringify_options(options)) def render(self, spec: Spec) -> str: """Render a Spec to an SVG string. @@ -161,11 +192,21 @@ class PDFWriter: Parameters ---------- width - Canvas width in pixels. + Canvas width in pixels (or in `units` if given). height - Canvas height in pixels. + Canvas height in pixels (or in `units` if given). dpi Canvas resolution in dots per inch. + **options + Additional writer options, validated by ggsql (an unknown key or + bad value raises ``ValueError``): + + - ``units``: ``"px"`` (default), ``"in"``, ``"cm"``, ``"mm"``, or + ``"pt"`` — how `width`/`height` are read. + - ``background``: any CSS color; ``"transparent"`` emits no + background (default is white). + - ``compress``: compress content streams (default ``True``). + - ``links``: preserve hyperlinks (default ``True``). Methods ------- @@ -173,11 +214,13 @@ class PDFWriter: Render a Spec to a PDF document (bytes). """ - def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: + def __init__( + self, width: int = 600, height: int = 400, dpi: float = 96.0, **options: Any + ) -> None: _validate_dimension("width", width) _validate_dimension("height", height) _validate_dpi(dpi) - self._inner = _RustPDFWriter(width, height, dpi) + self._inner = _RustPDFWriter(width, height, dpi, _stringify_options(options)) def render(self, spec: Spec) -> bytes: """Render a Spec to a PDF document as bytes. @@ -196,14 +239,44 @@ class HepWriter: A `.hep` document is a self-contained description of the resolved plot that a compatible consumer can render and re-layout at any size. + Parameters + ---------- + width, height, dpi + Optional size/resolution *hints* recorded in the document for the + consumer to default to — they do not fix the rendered size. If any + one is given, the others default to 600, 400, and 96.0 so the hint + is complete. By default no hint is recorded. + **options + Additional writer options, validated by ggsql (an unknown key or + bad value raises ``ValueError``): + + - ``background``: any CSS color; ``"transparent"`` for none. + - ``lossy``: drop what the format cannot carry (reported as render + warnings) instead of failing (default ``False``). + - ``embed_fonts``: inline the font files the plot's text needs + (default ``False``). + Methods ------- render(spec) Render a Spec to a .hep plot document (bytes). """ - def __init__(self) -> None: - self._inner = _RustHepWriter() + def __init__( + self, + width: int | None = None, + height: int | None = None, + dpi: float | None = None, + **options: Any, + ) -> None: + if width is not None or height is not None or dpi is not None: + width = 600 if width is None else width + height = 400 if height is None else height + dpi = 96.0 if dpi is None else dpi + _validate_dimension("width", width) + _validate_dimension("height", height) + _validate_dpi(dpi) + self._inner = _RustHepWriter(width, height, dpi, _stringify_options(options)) def render(self, spec: Spec) -> bytes: """Render a Spec to a .hep plot document as bytes. @@ -283,6 +356,7 @@ def save( width: int = 600, height: int = 400, dpi: float = 96.0, + **options: Any, ) -> Path: """Save a ggsql spec to a file. @@ -303,6 +377,10 @@ def save( Canvas height in pixels (ignored for `.hep` and `.json`). dpi Canvas resolution in dots per inch (ignored for `.hep` and `.json`). + **options + Additional writer options (e.g. ``background="transparent"``), + passed to the native writer — see `SVGWriter`, `PDFWriter`, and + `HepWriter` for the supported keys. Ignored for `.json`. Returns ------- @@ -317,11 +395,13 @@ def save( path = Path(file) ext = path.suffix.lower() if ext == ".svg": - path.write_text(SVGWriter(width, height, dpi).render(spec), encoding="utf-8") + path.write_text( + SVGWriter(width, height, dpi, **options).render(spec), encoding="utf-8" + ) elif ext == ".pdf": - path.write_bytes(PDFWriter(width, height, dpi).render(spec)) + path.write_bytes(PDFWriter(width, height, dpi, **options).render(spec)) elif ext == ".hep": - path.write_bytes(HepWriter().render(spec)) + path.write_bytes(HepWriter(**options).render(spec)) elif ext == ".json": path.write_text(VegaLiteWriter().render(spec), encoding="utf-8") else: diff --git a/src/lib.rs b/src/lib.rs index 2de8c59..c562d89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,7 @@ use ggsql::reader::{DuckDBReader as RustDuckDBReader, Reader}; use ggsql::validate::{validate as rust_validate, ValidationWarning}; use ggsql::writer::{ HepWriter as RustHepWriter, PdfWriter as RustPdfWriter, SvgWriter as RustSvgWriter, - VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter, + VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter, WriterOptions, }; use ggsql::GgsqlError; @@ -445,6 +445,25 @@ impl PyVegaLiteWriter { } } +// ============================================================================ +// Writer options +// ============================================================================ + +/// Collect extra key-value writer options into the crate's WriterOptions. +/// +/// Keys are normalised by the crate (case- and separator-insensitive), so +/// Python kwargs like `embed_fonts=True` arrive as-is and still match the +/// crate's hyphenated spellings. Typed canvas args (width/height/dpi) are +/// set by the caller after these, so they win any (impossible-from-Python) +/// conflict deterministically. +fn writer_options(extra: &[(String, String)]) -> WriterOptions { + let mut opts = WriterOptions::new(); + for (key, value) in extra { + opts = opts.set(key, value); + } + opts +} + // ============================================================================ // PySVGWriter // ============================================================================ @@ -476,16 +495,25 @@ impl PySVGWriter { /// Canvas height in pixels. /// dpi : float /// Canvas resolution in dots per inch. + /// options : list[tuple[str, str]] + /// Extra writer options (e.g. background, text, embed-fonts), + /// validated by the crate; an unknown key or bad value is an error. /// /// Returns /// ------- /// SVGWriter /// A configured SVG writer instance. #[new] - fn new(width: u32, height: u32, dpi: f64) -> Self { - Self { - inner: RustSvgWriter::new(width.max(1), height.max(1), dpi), - } + #[pyo3(signature = (width, height, dpi, options = Vec::new()))] + fn new(width: u32, height: u32, dpi: f64, options: Vec<(String, String)>) -> PyResult { + let opts = writer_options(&options) + .set("width", width.to_string()) + .set("height", height.to_string()) + .set("dpi", dpi.to_string()); + Ok(Self { + inner: RustSvgWriter::from_options(&opts) + .map_err(|e| PyErr::new::(e.to_string()))?, + }) } /// Render a Spec to SVG markup. @@ -550,10 +578,16 @@ impl PyPDFWriter { /// PDFWriter /// A configured PDF writer instance. #[new] - fn new(width: u32, height: u32, dpi: f64) -> Self { - Self { - inner: RustPdfWriter::new(width.max(1), height.max(1), dpi), - } + #[pyo3(signature = (width, height, dpi, options = Vec::new()))] + fn new(width: u32, height: u32, dpi: f64, options: Vec<(String, String)>) -> PyResult { + let opts = writer_options(&options) + .set("width", width.to_string()) + .set("height", height.to_string()) + .set("dpi", dpi.to_string()); + Ok(Self { + inner: RustPdfWriter::from_options(&opts) + .map_err(|e| PyErr::new::(e.to_string()))?, + }) } /// Render a Spec to a PDF document. @@ -611,10 +645,29 @@ impl PyHepWriter { /// HepWriter /// A configured .hep writer instance. #[new] - fn new() -> Self { - Self { - inner: RustHepWriter::default(), + #[pyo3(signature = (width = None, height = None, dpi = None, options = Vec::new()))] + fn new( + width: Option, + height: Option, + dpi: Option, + options: Vec<(String, String)>, + ) -> PyResult { + // Size/dpi are recorded as consumer hints only when asked for, so an + // optionless writer (the default) carries no hint at all. + let mut opts = writer_options(&options); + if let Some(width) = width { + opts = opts.set("width", width.to_string()); + } + if let Some(height) = height { + opts = opts.set("height", height.to_string()); } + if let Some(dpi) = dpi { + opts = opts.set("dpi", dpi.to_string()); + } + Ok(Self { + inner: RustHepWriter::from_options(&opts) + .map_err(|e| PyErr::new::(e.to_string()))?, + }) } /// Render a Spec to a .hep plot document. diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 8a6e319..0b439f8 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -842,6 +842,56 @@ def test_save_rejects_nonpositive_dimensions(self, tmp_path): ggsql.save(spec, tmp_path / "plot.svg", width=0) +class TestWriterOptions: + """The crate's extra writer options are reachable via **options and + validated by ggsql (unknown keys and bad values raise ValueError).""" + + @pytest.fixture + def spec(self): + reader = ggsql.DuckDBReader("duckdb://memory") + return reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") + + def test_background_option(self, spec): + default = ggsql.SVGWriter().render(spec) + transparent = ggsql.SVGWriter(background="transparent").render(spec) + assert default != transparent + + def test_text_outline_removes_text_elements(self, spec): + assert " 0 + + def test_save_passes_options_through(self, spec, tmp_path): + out = tmp_path / "plot.svg" + ggsql.save(spec, out, background="transparent") + assert " Date: Mon, 12 Oct 2026 11:43:33 -0500 Subject: [PATCH 13/17] Revert "feat: expose the crate's writer options as **options on native writers" This reverts commit 97c5a28bbf5beb92fa619144f605e8fde3c1e04b. --- CHANGELOG.md | 1 - README.md | 28 ++--------- python/ggsql/__init__.py | 106 +++++---------------------------------- src/lib.rs | 77 +++++----------------------- tests/test_ggsql.py | 50 ------------------ 5 files changed, 28 insertions(+), 234 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cee2237..19c02aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ - New native writers `SVGWriter`, `PDFWriter`, and `HepWriter`, rendered directly by ggsql (via hephaestus) with no JavaScript round-trip. Each exposes `render(spec)` returning `str` (SVG) or `bytes` (PDF/HEP); render warnings are surfaced via `warnings.warn()`. - New `ggsql.save(spec, file, ...)` helper that writes a spec to disk, picking the writer from the file extension (`.svg`, `.pdf`, `.hep`, or `.json` for Vega-Lite). -- The native writers (and `ggsql.save()`) accept the crate's additional writer options as keyword arguments — e.g. `background="transparent"`, `units="in"`, `text="outline"`, `embed_fonts=True`, `compress=False`. Unknown keys and invalid values raise `ValueError`. `HepWriter` can also record optional `width`/`height`/`dpi` hints for a consumer. - Enabled the crate's `builtin-data` feature, making builtin datasets such as `ggsql:world` available. ### Changed diff --git a/README.md b/README.md index 87e190b..1142b2e 100644 --- a/README.md +++ b/README.md @@ -161,47 +161,25 @@ writer = ggsql.VegaLiteWriter() json_output = writer.render(spec) ``` -#### `SVGWriter(width: int = 600, height: int = 400, dpi: float = 96.0, **options)` +#### `SVGWriter(width: int = 600, height: int = 400, dpi: float = 96.0)` Renders specs to SVG markup using ggsql's native renderer. - `render(spec: Spec) -> str` — Render to an SVG string. -#### `PDFWriter(width: int = 600, height: int = 400, dpi: float = 96.0, **options)` +#### `PDFWriter(width: int = 600, height: int = 400, dpi: float = 96.0)` Renders specs to PDF documents using ggsql's native renderer. - `render(spec: Spec) -> bytes` — Render to a PDF document. -#### `HepWriter(width=None, height=None, dpi=None, **options)` +#### `HepWriter()` Renders specs to `.hep` plot documents (self-contained plot descriptions a compatible consumer can re-layout at any size) using ggsql's native renderer. -The optional `width`/`height`/`dpi` are recorded as hints for the consumer, -not a fixed size; by default no hint is recorded. - `render(spec: Spec) -> bytes` — Render to a `.hep` document. -All three native writers accept extra keyword options, validated by ggsql -(unknown keys and bad values raise `ValueError`): - -- `units`: `"px"` (default), `"in"`, `"cm"`, `"mm"`, or `"pt"` — how - `width`/`height` are read. `SVGWriter(width=6, height=4, units="in", - dpi=300)` produces a 1800×1200 SVG that prints six inches wide. -- `background`: any CSS color; `"transparent"` emits no background - (default is white). -- SVG only: `text` (`"text"` or `"outline"` — draw glyphs as paths for a - self-contained file), `embed_fonts` (inline font files), `id_prefix` - (avoid id collisions when inlining several SVGs into one page). -- PDF only: `compress` (default `True`), `links` (default `True`). -- HEP only: `lossy` (drop what the format can't carry instead of failing), - `embed_fonts`. - -```python -svg = ggsql.SVGWriter(background="transparent").render(spec) -pdf = ggsql.PDFWriter(width=6, height=4, units="in", dpi=300).render(spec) -``` - #### `Validated` Result of `validate()` containing query analysis without SQL execution. diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index c141cfb..640152e 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -120,46 +120,17 @@ def _validate_dimension(name: str, value: int) -> None: raise ValueError(f"{name} must be a positive integer, got {value!r}") -def _stringify_options(options: dict[str, Any]) -> list[tuple[str, str]]: - """Convert writer option values to the strings the crate expects. - - Keys pass through unchanged: the crate normalises them (case- and - separator-insensitive), so ``embed_fonts=True`` matches the crate's - ``embed-fonts`` spelling. Booleans become ``"true"``/``"false"``. - """ - pairs = [] - for key, value in options.items(): - if isinstance(value, bool): - value = "true" if value else "false" - pairs.append((key, str(value))) - return pairs - - class SVGWriter: """SVG output writer using ggsql's native renderer. Parameters ---------- width - Canvas width in pixels (or in `units` if given). + Canvas width in pixels. height - Canvas height in pixels (or in `units` if given). + Canvas height in pixels. dpi Canvas resolution in dots per inch. - **options - Additional writer options, validated by ggsql (an unknown key or - bad value raises ``ValueError``): - - - ``units``: ``"px"`` (default), ``"in"``, ``"cm"``, ``"mm"``, or - ``"pt"`` — how `width`/`height` are read. - - ``background``: any CSS color; ``"transparent"`` emits no - background (default is white). - - ``text``: ``"text"`` (default, real selectable text) or - ``"outline"`` (glyphs as paths, self-contained). - - ``embed_fonts``: inline font files so the SVG renders identically - anywhere (default ``False``; can make files much larger). - - ``id_prefix``: prefix for generated element ids (avoids id - collisions when inlining multiple SVGs into one page). Methods ------- @@ -167,13 +138,11 @@ class SVGWriter: Render a Spec to an SVG string. """ - def __init__( - self, width: int = 600, height: int = 400, dpi: float = 96.0, **options: Any - ) -> None: + def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: _validate_dimension("width", width) _validate_dimension("height", height) _validate_dpi(dpi) - self._inner = _RustSVGWriter(width, height, dpi, _stringify_options(options)) + self._inner = _RustSVGWriter(width, height, dpi) def render(self, spec: Spec) -> str: """Render a Spec to an SVG string. @@ -192,21 +161,11 @@ class PDFWriter: Parameters ---------- width - Canvas width in pixels (or in `units` if given). + Canvas width in pixels. height - Canvas height in pixels (or in `units` if given). + Canvas height in pixels. dpi Canvas resolution in dots per inch. - **options - Additional writer options, validated by ggsql (an unknown key or - bad value raises ``ValueError``): - - - ``units``: ``"px"`` (default), ``"in"``, ``"cm"``, ``"mm"``, or - ``"pt"`` — how `width`/`height` are read. - - ``background``: any CSS color; ``"transparent"`` emits no - background (default is white). - - ``compress``: compress content streams (default ``True``). - - ``links``: preserve hyperlinks (default ``True``). Methods ------- @@ -214,13 +173,11 @@ class PDFWriter: Render a Spec to a PDF document (bytes). """ - def __init__( - self, width: int = 600, height: int = 400, dpi: float = 96.0, **options: Any - ) -> None: + def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> None: _validate_dimension("width", width) _validate_dimension("height", height) _validate_dpi(dpi) - self._inner = _RustPDFWriter(width, height, dpi, _stringify_options(options)) + self._inner = _RustPDFWriter(width, height, dpi) def render(self, spec: Spec) -> bytes: """Render a Spec to a PDF document as bytes. @@ -239,44 +196,14 @@ class HepWriter: A `.hep` document is a self-contained description of the resolved plot that a compatible consumer can render and re-layout at any size. - Parameters - ---------- - width, height, dpi - Optional size/resolution *hints* recorded in the document for the - consumer to default to — they do not fix the rendered size. If any - one is given, the others default to 600, 400, and 96.0 so the hint - is complete. By default no hint is recorded. - **options - Additional writer options, validated by ggsql (an unknown key or - bad value raises ``ValueError``): - - - ``background``: any CSS color; ``"transparent"`` for none. - - ``lossy``: drop what the format cannot carry (reported as render - warnings) instead of failing (default ``False``). - - ``embed_fonts``: inline the font files the plot's text needs - (default ``False``). - Methods ------- render(spec) Render a Spec to a .hep plot document (bytes). """ - def __init__( - self, - width: int | None = None, - height: int | None = None, - dpi: float | None = None, - **options: Any, - ) -> None: - if width is not None or height is not None or dpi is not None: - width = 600 if width is None else width - height = 400 if height is None else height - dpi = 96.0 if dpi is None else dpi - _validate_dimension("width", width) - _validate_dimension("height", height) - _validate_dpi(dpi) - self._inner = _RustHepWriter(width, height, dpi, _stringify_options(options)) + def __init__(self) -> None: + self._inner = _RustHepWriter() def render(self, spec: Spec) -> bytes: """Render a Spec to a .hep plot document as bytes. @@ -356,7 +283,6 @@ def save( width: int = 600, height: int = 400, dpi: float = 96.0, - **options: Any, ) -> Path: """Save a ggsql spec to a file. @@ -377,10 +303,6 @@ def save( Canvas height in pixels (ignored for `.hep` and `.json`). dpi Canvas resolution in dots per inch (ignored for `.hep` and `.json`). - **options - Additional writer options (e.g. ``background="transparent"``), - passed to the native writer — see `SVGWriter`, `PDFWriter`, and - `HepWriter` for the supported keys. Ignored for `.json`. Returns ------- @@ -395,13 +317,11 @@ def save( path = Path(file) ext = path.suffix.lower() if ext == ".svg": - path.write_text( - SVGWriter(width, height, dpi, **options).render(spec), encoding="utf-8" - ) + path.write_text(SVGWriter(width, height, dpi).render(spec), encoding="utf-8") elif ext == ".pdf": - path.write_bytes(PDFWriter(width, height, dpi, **options).render(spec)) + path.write_bytes(PDFWriter(width, height, dpi).render(spec)) elif ext == ".hep": - path.write_bytes(HepWriter(**options).render(spec)) + path.write_bytes(HepWriter().render(spec)) elif ext == ".json": path.write_text(VegaLiteWriter().render(spec), encoding="utf-8") else: diff --git a/src/lib.rs b/src/lib.rs index c562d89..2de8c59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,7 @@ use ggsql::reader::{DuckDBReader as RustDuckDBReader, Reader}; use ggsql::validate::{validate as rust_validate, ValidationWarning}; use ggsql::writer::{ HepWriter as RustHepWriter, PdfWriter as RustPdfWriter, SvgWriter as RustSvgWriter, - VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter, WriterOptions, + VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter, }; use ggsql::GgsqlError; @@ -445,25 +445,6 @@ impl PyVegaLiteWriter { } } -// ============================================================================ -// Writer options -// ============================================================================ - -/// Collect extra key-value writer options into the crate's WriterOptions. -/// -/// Keys are normalised by the crate (case- and separator-insensitive), so -/// Python kwargs like `embed_fonts=True` arrive as-is and still match the -/// crate's hyphenated spellings. Typed canvas args (width/height/dpi) are -/// set by the caller after these, so they win any (impossible-from-Python) -/// conflict deterministically. -fn writer_options(extra: &[(String, String)]) -> WriterOptions { - let mut opts = WriterOptions::new(); - for (key, value) in extra { - opts = opts.set(key, value); - } - opts -} - // ============================================================================ // PySVGWriter // ============================================================================ @@ -495,25 +476,16 @@ impl PySVGWriter { /// Canvas height in pixels. /// dpi : float /// Canvas resolution in dots per inch. - /// options : list[tuple[str, str]] - /// Extra writer options (e.g. background, text, embed-fonts), - /// validated by the crate; an unknown key or bad value is an error. /// /// Returns /// ------- /// SVGWriter /// A configured SVG writer instance. #[new] - #[pyo3(signature = (width, height, dpi, options = Vec::new()))] - fn new(width: u32, height: u32, dpi: f64, options: Vec<(String, String)>) -> PyResult { - let opts = writer_options(&options) - .set("width", width.to_string()) - .set("height", height.to_string()) - .set("dpi", dpi.to_string()); - Ok(Self { - inner: RustSvgWriter::from_options(&opts) - .map_err(|e| PyErr::new::(e.to_string()))?, - }) + fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + inner: RustSvgWriter::new(width.max(1), height.max(1), dpi), + } } /// Render a Spec to SVG markup. @@ -578,16 +550,10 @@ impl PyPDFWriter { /// PDFWriter /// A configured PDF writer instance. #[new] - #[pyo3(signature = (width, height, dpi, options = Vec::new()))] - fn new(width: u32, height: u32, dpi: f64, options: Vec<(String, String)>) -> PyResult { - let opts = writer_options(&options) - .set("width", width.to_string()) - .set("height", height.to_string()) - .set("dpi", dpi.to_string()); - Ok(Self { - inner: RustPdfWriter::from_options(&opts) - .map_err(|e| PyErr::new::(e.to_string()))?, - }) + fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + inner: RustPdfWriter::new(width.max(1), height.max(1), dpi), + } } /// Render a Spec to a PDF document. @@ -645,29 +611,10 @@ impl PyHepWriter { /// HepWriter /// A configured .hep writer instance. #[new] - #[pyo3(signature = (width = None, height = None, dpi = None, options = Vec::new()))] - fn new( - width: Option, - height: Option, - dpi: Option, - options: Vec<(String, String)>, - ) -> PyResult { - // Size/dpi are recorded as consumer hints only when asked for, so an - // optionless writer (the default) carries no hint at all. - let mut opts = writer_options(&options); - if let Some(width) = width { - opts = opts.set("width", width.to_string()); - } - if let Some(height) = height { - opts = opts.set("height", height.to_string()); - } - if let Some(dpi) = dpi { - opts = opts.set("dpi", dpi.to_string()); + fn new() -> Self { + Self { + inner: RustHepWriter::default(), } - Ok(Self { - inner: RustHepWriter::from_options(&opts) - .map_err(|e| PyErr::new::(e.to_string()))?, - }) } /// Render a Spec to a .hep plot document. diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 0b439f8..8a6e319 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -842,56 +842,6 @@ def test_save_rejects_nonpositive_dimensions(self, tmp_path): ggsql.save(spec, tmp_path / "plot.svg", width=0) -class TestWriterOptions: - """The crate's extra writer options are reachable via **options and - validated by ggsql (unknown keys and bad values raise ValueError).""" - - @pytest.fixture - def spec(self): - reader = ggsql.DuckDBReader("duckdb://memory") - return reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") - - def test_background_option(self, spec): - default = ggsql.SVGWriter().render(spec) - transparent = ggsql.SVGWriter(background="transparent").render(spec) - assert default != transparent - - def test_text_outline_removes_text_elements(self, spec): - assert " 0 - - def test_save_passes_options_through(self, spec, tmp_path): - out = tmp_path / "plot.svg" - ggsql.save(spec, out, background="transparent") - assert " Date: Mon, 12 Oct 2026 11:57:42 -0500 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20document=20fontconfig=20runtime=20dep,=20add=20whee?= =?UTF-8?q?l=20smoke=20test=20and=20writer=20warning=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: document that native SVG/PDF/HEP writers dlopen libfontconfig.so.1 on Linux and need installed fonts; renders silently lose text otherwise - CI: smoke-test the built wheel in a python:3.12-slim container provisioned per the README, asserting rendered SVG retains its text - tests: exercise the render-issue warning path with a stubbed inner writer returning non-empty issues (pytest.warns + output preserved), plus a no-warning case for an empty issue list --- .github/workflows/python.yml | 38 +++++++++++++++++++++++++++ README.md | 10 +++++++ scripts/smoke_test_wheel.py | 30 +++++++++++++++++++++ tests/test_ggsql.py | 51 ++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 scripts/smoke_test_wheel.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3f63520..9628552 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -77,3 +77,41 @@ jobs: - name: Run tests shell: bash run: pytest tests/ -v + + # The abi3 wheel is identical across Python legs; keep just one for + # the wheel smoke test below. + - name: Upload Linux wheel + if: runner.os == 'Linux' && matrix.python == '3.13' + uses: actions/upload-artifact@v4 + with: + name: ggsql-wheel + path: target/wheels/ggsql-*.whl + retention-days: 5 + + # Native SVG/PDF/HEP writers dlopen libfontconfig.so.1 at runtime and + # need installed fonts — neither is expressible in wheel metadata, and + # slim Linux images lack both. Render in a minimal container provisioned + # per the README to catch packaging/runtime regressions. + wheel-smoke-test: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/download-artifact@v4 + with: + name: ggsql-wheel + path: wheelhouse + + - uses: actions/checkout@v4 + + - name: Smoke test wheel in a minimal container + run: | + WHEEL=$(basename wheelhouse/*.whl) + docker run --rm \ + -v "$GITHUB_WORKSPACE/wheelhouse:/wheels:ro" \ + -v "$GITHUB_WORKSPACE/scripts/smoke_test_wheel.py:/smoke_test.py:ro" \ + python:3.12-slim bash -euxo pipefail -c " + apt-get update && + apt-get install -y --no-install-recommends libfontconfig1 fonts-dejavu-core && + pip install --no-cache-dir '/wheels/$WHEEL' && + python /smoke_test.py + " diff --git a/README.md b/README.md index 1142b2e..aba1716 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,16 @@ ggsql.save(spec, "plot.hep") ggsql.save(spec, "plot.json") # Vega-Lite JSON ``` +> **Linux runtime requirement:** the native writers load `libfontconfig.so.1` +> at runtime (via `dlopen`) and need at least one installed font for text +> layout. Prebuilt wheels do not bundle these. On slim/container images +> (e.g., `python:*-slim`), install them explicitly: +> +> ```sh +> apt-get install -y libfontconfig1 fonts-dejavu-core +> ``` +> +> Without fontconfig, renders silently drop all text. ## API Reference ### Classes diff --git a/scripts/smoke_test_wheel.py b/scripts/smoke_test_wheel.py new file mode 100644 index 0000000..d01f5c8 --- /dev/null +++ b/scripts/smoke_test_wheel.py @@ -0,0 +1,30 @@ +"""Smoke test: native writers render text in a minimal Linux runtime. + +Intended to run inside a slim container (e.g., python:*-slim) against an +installed ggsql wheel, after provisioning the documented runtime +dependencies (libfontconfig1 + fonts-dejavu-core — see README "Native +Writers"). Catches packaging omissions and missing system dependencies: +without fontconfig, native renders silently lose all text. +""" + +import ggsql + +reader = ggsql.DuckDBReader("duckdb://memory") +spec = reader.execute( + "SELECT * FROM ggsql:penguins " + "VISUALISE DRAW point MAPPING bill_len AS x, bill_dep AS y" +) + +svg = ggsql.SVGWriter().render(spec) +assert " 0, "HEP output is empty" + +print(f"smoke test OK: svg={len(svg)}B pdf={len(pdf)}B hep={len(hep)}B") diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 8a6e319..437dc58 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -10,6 +10,7 @@ import json import re +import warnings from pathlib import Path import duckdb @@ -802,6 +803,56 @@ def test_save_invalid_dpi_raises(self, spec, tmp_path): ggsql.save(spec, tmp_path / "plot.svg", dpi=0) +class TestWriterWarnings: + """Writer render issues (from Rust's render_reporting) are surfaced as + UserWarnings while the rendered output is returned unchanged.""" + + class _StubInner: + """Stand-in for the Rust writer returning a fixed (output, issues).""" + + def __init__(self, output, issues): + self._output = output + self._issues = issues + + def render(self, _spec): + return self._output, self._issues + + @pytest.fixture + def spec(self): + reader = ggsql.DuckDBReader("duckdb://memory") + return reader.execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") + + @pytest.mark.parametrize( + "writer_cls,output", + [ + (ggsql.SVGWriter, ""), + (ggsql.PDFWriter, b"%PDF-stub"), + (ggsql.HepWriter, b"hep-stub"), + ], + ) + def test_render_issues_emit_userwarning(self, spec, writer_cls, output): + writer = writer_cls() + issues = ["font 'Foo' not found", "missing glyph for '×'"] + writer._inner = self._StubInner(output, issues) + + with pytest.warns(UserWarning, match="2 issue") as record: + result = writer.render(spec) + + assert result == output + message = str(record[0].message) + assert all(issue in message for issue in issues) + + def test_empty_issues_emit_no_warning(self, spec): + writer = ggsql.SVGWriter() + writer._inner = self._StubInner("", []) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + result = writer.render(spec) + + assert result == "" + + class TestVersion: def test_version_matches_pyproject(self): pyproject = Path(__file__).parent.parent / "pyproject.toml" From d765f36446b745e249242e6ee83559e52dd60eea Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 12:01:27 -0500 Subject: [PATCH 15/17] feat: point raster save() attempts at SVG conversion .png/.jpg/.jpeg/.tif/.tiff/.webp now raise a targeted ValueError explaining that ggsql's raster writers need a GPU adapter (unavailable in some environments) and suggesting .svg/.pdf plus a converter of the user's choice, instead of the generic unsupported-extension message they previously shared with typos. The GPU-backed raster writers stay disabled; this only improves the error UX. --- python/ggsql/__init__.py | 11 ++++++++++- tests/test_ggsql.py | 9 ++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index 640152e..e6d1ec1 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -312,7 +312,9 @@ def save( Raises ------ ValueError - If the file extension is not supported. + If the file extension is not supported. Raster extensions (`.png`, + `.jpg`, `.tif`, `.webp`) raise with a suggestion to save as `.svg` + and convert, since ggsql's raster writers need a GPU adapter. """ path = Path(file) ext = path.suffix.lower() @@ -324,6 +326,13 @@ def save( path.write_bytes(HepWriter().render(spec)) elif ext == ".json": path.write_text(VegaLiteWriter().render(spec), encoding="utf-8") + elif ext in (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp"): + raise ValueError( + f"Raster output ({ext!r}) is not supported: ggsql's raster writers " + "require a GPU adapter, which is not available in every environment. " + "Save as .svg (or .pdf) and convert with a tool of your choice " + "(e.g. ImageMagick, rsvg-convert, or a browser)." + ) else: raise ValueError( f"Unsupported file extension {ext!r}. Use .svg, .pdf, .hep, or .json." diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 437dc58..7ee58d0 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -796,7 +796,14 @@ def test_save_with_dimensions(self, spec, tmp_path): def test_save_unknown_extension_raises(self, spec, tmp_path): with pytest.raises(ValueError, match="Unsupported file extension"): - ggsql.save(spec, tmp_path / "plot.png") + ggsql.save(spec, tmp_path / "plot.xyz") + + @pytest.mark.parametrize( + "ext", [".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp"] + ) + def test_save_raster_extension_suggests_svg(self, spec, tmp_path, ext): + with pytest.raises(ValueError, match=r"Save as \.svg"): + ggsql.save(spec, tmp_path / f"plot{ext}") def test_save_invalid_dpi_raises(self, spec, tmp_path): with pytest.raises(ValueError, match="dpi must be a positive"): From 4d1ba284fc654e8b9f8191ca5ab0993082b87a23 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 12:19:44 -0500 Subject: [PATCH 16/17] feat: add __repr__ to writer classes SVGWriter/PDFWriter show their constructor dimensions; HepWriter and VegaLiteWriter show the bare call. Pure Python-layer change on the public wrappers. --- python/ggsql/__init__.py | 24 ++++++++++++++++++++++++ tests/test_ggsql.py | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/python/ggsql/__init__.py b/python/ggsql/__init__.py index e6d1ec1..76feee7 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -142,8 +142,17 @@ def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> No _validate_dimension("width", width) _validate_dimension("height", height) _validate_dpi(dpi) + self._width = width + self._height = height + self._dpi = dpi self._inner = _RustSVGWriter(width, height, dpi) + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(width={self._width!r}, " + f"height={self._height!r}, dpi={self._dpi!r})" + ) + def render(self, spec: Spec) -> str: """Render a Spec to an SVG string. @@ -177,8 +186,17 @@ def __init__(self, width: int = 600, height: int = 400, dpi: float = 96.0) -> No _validate_dimension("width", width) _validate_dimension("height", height) _validate_dpi(dpi) + self._width = width + self._height = height + self._dpi = dpi self._inner = _RustPDFWriter(width, height, dpi) + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(width={self._width!r}, " + f"height={self._height!r}, dpi={self._dpi!r})" + ) + def render(self, spec: Spec) -> bytes: """Render a Spec to a PDF document as bytes. @@ -205,6 +223,9 @@ class HepWriter: def __init__(self) -> None: self._inner = _RustHepWriter() + def __repr__(self) -> str: + return f"{type(self).__name__}()" + def render(self, spec: Spec) -> bytes: """Render a Spec to a .hep plot document as bytes. @@ -230,6 +251,9 @@ class VegaLiteWriter: def __init__(self) -> None: self._inner = _RustVegaLiteWriter() + def __repr__(self) -> str: + return f"{type(self).__name__}()" + def render(self, spec: Spec) -> str: """Render a Spec to a Vega-Lite JSON string.""" return self._inner.render(spec) diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 7ee58d0..22a11da 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -762,6 +762,28 @@ def test_render_to_hep(self): assert len(doc) > 0 +class TestWriterRepr: + def test_svg_writer_repr(self): + assert repr(ggsql.SVGWriter()) == "SVGWriter(width=600, height=400, dpi=96.0)" + assert ( + repr(ggsql.SVGWriter(width=800, height=600, dpi=150)) + == "SVGWriter(width=800, height=600, dpi=150)" + ) + + def test_pdf_writer_repr(self): + assert repr(ggsql.PDFWriter()) == "PDFWriter(width=600, height=400, dpi=96.0)" + assert ( + repr(ggsql.PDFWriter(width=800, height=600, dpi=150.5)) + == "PDFWriter(width=800, height=600, dpi=150.5)" + ) + + def test_hep_writer_repr(self): + assert repr(ggsql.HepWriter()) == "HepWriter()" + + def test_vegalite_writer_repr(self): + assert repr(ggsql.VegaLiteWriter()) == "VegaLiteWriter()" + + class TestSave: @pytest.fixture def spec(self): From a55d36bf5decc3daf9ad87faf873af2aa0f7b1e3 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 12:30:01 -0500 Subject: [PATCH 17/17] ci: checkout before downloading wheel artifact (checkout cleans workspace) --- .github/workflows/python.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9628552..b359ca0 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -96,13 +96,14 @@ jobs: runs-on: ubuntu-latest needs: test steps: + # NOTE: checkout must precede the download — it cleans the workspace. + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: name: ggsql-wheel path: wheelhouse - - uses: actions/checkout@v4 - - name: Smoke test wheel in a minimal container run: | WHEEL=$(basename wheelhouse/*.whl)