diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 752c0b8..b359ca0 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: @@ -68,3 +77,42 @@ 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: + # 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 + + - 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/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3616c4f..19c02aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,26 @@ # 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. +- 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 @@ -12,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 7485e21..adda96b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,16 @@ 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. + +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. 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..04181c4 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.88" [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/README.md b/README.md index 81782a5..aba1716 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,39 @@ 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 +``` + +> **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 @@ -138,6 +171,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 +264,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 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..76feee7 100644 --- a/python/ggsql/__init__.py +++ b/python/ggsql/__init__.py @@ -1,6 +1,11 @@ from __future__ import annotations 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 import altair @@ -9,6 +14,9 @@ from ggsql._ggsql import ( DuckDBReader, + HepWriter as _RustHepWriter, + PDFWriter as _RustPDFWriter, + SVGWriter as _RustSVGWriter, VegaLiteWriter as _RustVegaLiteWriter, Validated, Spec, @@ -18,13 +26,24 @@ # 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, + _RustHepWriter, + _RustPDFWriter, + _RustSVGWriter, + _RustVegaLiteWriter, + Validated, + Spec, +): _cls.__module__ = "ggsql._ggsql" del _cls __all__ = [ # Classes "DuckDBReader", + "SVGWriter", + "PDFWriter", + "HepWriter", "VegaLiteWriter", "Validated", "Spec", @@ -32,8 +51,13 @@ "validate", "execute", "render_altair", + "save", ] -__version__ = "0.3.0" + +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[ @@ -47,6 +71,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 +108,135 @@ 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}") + + +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. + + 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: + _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. + + Render warnings reported by the writer are surfaced via + ``warnings.warn()``. + """ + output, issues = self._inner.render(spec) + _warn_render_issues(issues) + 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: + _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. + + 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 __repr__(self) -> str: + return f"{type(self).__name__}()" + + 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. @@ -89,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) @@ -135,6 +300,70 @@ 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. 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() + 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") + 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." + ) + return path + + def render_altair( df: IntoFrame, viz: str, 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/src/lib.rs b/src/lib.rs index 822c2d3..2de8c59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,10 @@ 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::{ + HepWriter as RustHepWriter, PdfWriter as RustPdfWriter, SvgWriter as RustSvgWriter, + VegaLiteWriter as RustVegaLiteWriter, Writer as RustWriter, +}; use ggsql::GgsqlError; // ============================================================================ @@ -442,6 +445,204 @@ 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, 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())) + } +} + +// ============================================================================ +// 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)> { + // 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)) + } +} + +// ============================================================================ +// 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)> { + // 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)) + } +} + // ============================================================================ // PyValidated // ============================================================================ @@ -795,6 +996,9 @@ 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::()?; m.add_class::()?; m.add_class::()?; diff --git a/tests/test_ggsql.py b/tests/test_ggsql.py index 2691d74..22a11da 100644 --- a/tests/test_ggsql.py +++ b/tests/test_ggsql.py @@ -9,6 +9,9 @@ """ import json +import re +import warnings +from pathlib import Path import duckdb import pyarrow as pa @@ -59,14 +62,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: @@ -703,3 +707,254 @@ 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 " 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): + 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 ""), + (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" + 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): + 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 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 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).""" + + 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 "