From 0f43a4e3a2debe88b56819fa7d96b9ce2ccd8099 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:46:02 +0100 Subject: [PATCH 01/11] feat: evidence policy file and strict comparator --- Cargo.lock | 1377 ++++++++++++++++- Cargo.toml | 8 + crates/sqlquack-compare/Cargo.toml | 4 +- .../src/comparison/_helpers/mod.rs | 2 + .../src/comparison/_helpers/rows.rs | 116 ++ .../src/comparison/_helpers/type_names.rs | 36 + .../src/comparison/main/compare_recordings.rs | 41 + .../comparison/main/compare_with_replay.rs | 73 + .../src/comparison/main/mod.rs | 2 + crates/sqlquack-compare/src/comparison/mod.rs | 8 + .../sqlquack-compare/src/comparison/models.rs | 64 + .../sqlquack-compare/src/comparison/tests.rs | 8 + .../src/comparison/tests/helpers.rs | 141 ++ .../tests/test_compare_recordings.rs | 40 + .../tests/test_compare_with_replay.rs | 192 +++ .../src/comparison/tests/test_types.rs | 26 + crates/sqlquack-compare/src/lib.rs | 5 +- .../src/values/_helpers/decode.rs | 233 +++ .../src/values/_helpers/mod.rs | 1 + .../src/values/main/canonical_rows.rs | 20 + .../sqlquack-compare/src/values/main/mod.rs | 1 + crates/sqlquack-compare/src/values/mod.rs | 5 + crates/sqlquack-compare/src/values/models.rs | 48 + crates/sqlquack-evidence/Cargo.toml | 28 + crates/sqlquack-evidence/src/lib.rs | 1 + crates/sqlquack-policy/Cargo.toml | 17 + crates/sqlquack-policy/src/lib.rs | 5 + .../src/policy/_helpers/mod.rs | 1 + .../src/policy/_helpers/validation.rs | 184 +++ .../sqlquack-policy/src/policy/constants.rs | 7 + crates/sqlquack-policy/src/policy/errors.rs | 34 + .../src/policy/main/load_policy.rs | 14 + .../src/policy/main/matching_type_equality.rs | 50 + crates/sqlquack-policy/src/policy/main/mod.rs | 4 + .../src/policy/main/parse_policy.rs | 17 + .../src/policy/main/parse_type_pattern.rs | 53 + crates/sqlquack-policy/src/policy/mod.rs | 10 + crates/sqlquack-policy/src/policy/models.rs | 182 +++ crates/sqlquack-policy/src/policy/tests.rs | 8 + .../src/policy/tests/helpers.rs | 65 + .../src/policy/tests/test_parse_policy.rs | 83 + .../src/policy/tests/test_type_equality.rs | 41 + .../src/policy/tests/test_types.rs | 13 + sqlquack.toml | 122 ++ 44 files changed, 3329 insertions(+), 61 deletions(-) create mode 100644 crates/sqlquack-compare/src/comparison/_helpers/mod.rs create mode 100644 crates/sqlquack-compare/src/comparison/_helpers/rows.rs create mode 100644 crates/sqlquack-compare/src/comparison/_helpers/type_names.rs create mode 100644 crates/sqlquack-compare/src/comparison/main/compare_recordings.rs create mode 100644 crates/sqlquack-compare/src/comparison/main/compare_with_replay.rs create mode 100644 crates/sqlquack-compare/src/comparison/main/mod.rs create mode 100644 crates/sqlquack-compare/src/comparison/mod.rs create mode 100644 crates/sqlquack-compare/src/comparison/models.rs create mode 100644 crates/sqlquack-compare/src/comparison/tests.rs create mode 100644 crates/sqlquack-compare/src/comparison/tests/helpers.rs create mode 100644 crates/sqlquack-compare/src/comparison/tests/test_compare_recordings.rs create mode 100644 crates/sqlquack-compare/src/comparison/tests/test_compare_with_replay.rs create mode 100644 crates/sqlquack-compare/src/comparison/tests/test_types.rs create mode 100644 crates/sqlquack-compare/src/values/_helpers/decode.rs create mode 100644 crates/sqlquack-compare/src/values/_helpers/mod.rs create mode 100644 crates/sqlquack-compare/src/values/main/canonical_rows.rs create mode 100644 crates/sqlquack-compare/src/values/main/mod.rs create mode 100644 crates/sqlquack-compare/src/values/mod.rs create mode 100644 crates/sqlquack-compare/src/values/models.rs create mode 100644 crates/sqlquack-evidence/Cargo.toml create mode 100644 crates/sqlquack-evidence/src/lib.rs create mode 100644 crates/sqlquack-policy/Cargo.toml create mode 100644 crates/sqlquack-policy/src/lib.rs create mode 100644 crates/sqlquack-policy/src/policy/_helpers/mod.rs create mode 100644 crates/sqlquack-policy/src/policy/_helpers/validation.rs create mode 100644 crates/sqlquack-policy/src/policy/constants.rs create mode 100644 crates/sqlquack-policy/src/policy/errors.rs create mode 100644 crates/sqlquack-policy/src/policy/main/load_policy.rs create mode 100644 crates/sqlquack-policy/src/policy/main/matching_type_equality.rs create mode 100644 crates/sqlquack-policy/src/policy/main/mod.rs create mode 100644 crates/sqlquack-policy/src/policy/main/parse_policy.rs create mode 100644 crates/sqlquack-policy/src/policy/main/parse_type_pattern.rs create mode 100644 crates/sqlquack-policy/src/policy/mod.rs create mode 100644 crates/sqlquack-policy/src/policy/models.rs create mode 100644 crates/sqlquack-policy/src/policy/tests.rs create mode 100644 crates/sqlquack-policy/src/policy/tests/helpers.rs create mode 100644 crates/sqlquack-policy/src/policy/tests/test_parse_policy.rs create mode 100644 crates/sqlquack-policy/src/policy/tests/test_type_equality.rs create mode 100644 crates/sqlquack-policy/src/policy/tests/test_types.rs create mode 100644 sqlquack.toml diff --git a/Cargo.lock b/Cargo.lock index 1ffaea41b..017b0da98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -11,6 +31,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -47,7 +76,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +87,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -70,12 +99,252 @@ dependencies = [ "object", ] +[[package]] +name = "arrow" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ce58e76c04e8162c2e4bc3fe53fa7c2418ef5fba7bb2838b8531b7d4fbf14" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ipc", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2303ebedb1071f4f72d71171e383859a9a285c94d99e4892685a96fca9890720" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dff6e4b9f158864a0aeb6a131ad53858ecbae7a2fc8989307b9b3c6b2f122e" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "libc", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7235b863533e9db3ab86905b4521251de11226275b436ff3cb2d0010558d1c5" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cf840f0c34aeeb7e8b05aba72d66878e6c001827f423331f0b260209e7b7e" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-cmp" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73a0e9adb28d95739fabcdae637261a2c14e618caf58ba1b88857120f9ee2687" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", +] + +[[package]] +name = "arrow-data" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa06d5e22786c4966ffc4d2f171d0cb65d95f6bdea108a14e8c335e9faa437f" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86c523472d22e31f984df7a44fc36e6323d3c9b05ffe53d267ee4c3aa7c128d2" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", +] + +[[package]] +name = "arrow-ord" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7ac2c6a9cdc782fa9548d3763461eaec8c93347e297fddbaa44807e70a3698" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cmp", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758b87e9648d80001e0916c083334ed93074c677a81a96144d5c84bd2d8eab2a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f85a9a58cd03560526b0066487574aacb9008e413504fa0ee23d61141e96c338" +dependencies = [ + "bitflags", +] + +[[package]] +name = "arrow-select" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3482b7d03bf4a3bf0b195f08a7f2b071595ccd54897e35b975ac63d3104977" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-cmp", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "60.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f300b4400b1e7cd58f7cbbd5dd2a0072cc59e73bfaee2a152b3ac660e46d9dd1" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "atoi" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d12c37257553c19e9aee6cba77814fdc9e07844abfc8b3a2c377cd920ee5a993" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.10.0" @@ -92,6 +361,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.6.7" @@ -138,6 +418,60 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "comfy-table" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96a46ad395a5e00676eddaca4a45ed78b29c08be5a878d35e4b071c625637dd" +dependencies = [ + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -163,6 +497,48 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "duckdb" +version = "1.20000.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54411716279c39d685e0f3f10346ff503def53fd9b8aa8fbf4ea1db4499c312a" +dependencies = [ + "arrow", + "cast", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libduckdb-sys", + "num-integer", + "strum", +] + [[package]] name = "either" version = "1.19.0" @@ -176,15 +552,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -192,96 +590,417 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] -name = "getrandom" -version = "0.4.3" +name = "flatbuffers" +version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "cfg-if", - "libc", - "r-efi", + "bitflags", + "rustc_version", ] [[package]] -name = "heck" -version = "0.5.0" +name = "flate2" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "itoa" -version = "1.0.18" +name = "futures-core" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] -name = "libc" -version = "0.2.191" +name = "futures-task" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "futures-util" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] [[package]] -name = "memchr" -version = "2.8.3" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] [[package]] -name = "object" -version = "0.39.1" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "memchr", + "cfg-if", + "libc", + "wasi", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] [[package]] -name = "polyglot-sql" -version = "0.13.9" -source = "git+https://github.com/chio-labs/polyglot.git?rev=b18dc647e797e480d2faadf3af407077f8e063df#b18dc647e797e480d2faadf3af407077f8e063df" +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "polyglot-sql-ast-derive", - "polyglot-sql-function-catalogs", - "regex", - "serde", - "serde_json", - "stacker", - "thiserror", - "unicode-segmentation", + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", ] [[package]] -name = "polyglot-sql-ast-derive" -version = "0.13.9" -source = "git+https://github.com/chio-labs/polyglot.git?rev=b18dc647e797e480d2faadf3af407077f8e063df#b18dc647e797e480d2faadf3af407077f8e063df" +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libduckdb-sys" +version = "1.20000.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1dcdcfde48e11bbd46112ed3b0423acd8c644413b3891e7be8104edce7fdda" +dependencies = [ + "flate2", + "pkg-config", + "serde", + "serde_json", + "tar", + "ureq", + "vcpkg", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyglot-sql" +version = "0.13.9" +source = "git+https://github.com/chio-labs/polyglot.git?rev=b18dc647e797e480d2faadf3af407077f8e063df#b18dc647e797e480d2faadf3af407077f8e063df" +dependencies = [ + "polyglot-sql-ast-derive", + "polyglot-sql-function-catalogs", + "regex", + "serde", + "serde_json", + "stacker", + "thiserror", + "unicode-segmentation", +] + +[[package]] +name = "polyglot-sql-ast-derive" +version = "0.13.9" +source = "git+https://github.com/chio-labs/polyglot.git?rev=b18dc647e797e480d2faadf3af407077f8e063df#b18dc647e797e480d2faadf3af407077f8e063df" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -317,6 +1036,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -372,6 +1097,29 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" @@ -382,9 +1130,56 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -394,6 +1189,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -437,12 +1238,44 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "sqlquack" version = "0.2.0" @@ -463,6 +1296,10 @@ dependencies = [ [[package]] name = "sqlquack-compare" version = "0.2.0" +dependencies = [ + "arrow", + "sqlquack-policy", +] [[package]] name = "sqlquack-core" @@ -477,6 +1314,31 @@ dependencies = [ name = "sqlquack-duckdb" version = "0.2.0" +[[package]] +name = "sqlquack-evidence" +version = "0.2.0" +dependencies = [ + "arrow", + "duckdb", + "serde", + "serde_json", + "sha2", + "sqlquack-compare", + "sqlquack-core", + "sqlquack-policy", + "tempfile", + "toml", + "walkdir", +] + +[[package]] +name = "sqlquack-policy" +version = "0.2.0" +dependencies = [ + "serde", + "toml", +] + [[package]] name = "sqlquack-rules" version = "0.2.0" @@ -498,7 +1360,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -507,6 +1369,33 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -529,6 +1418,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -536,10 +1436,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -562,6 +1462,52 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -574,12 +1520,70 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -590,13 +1594,117 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -605,6 +1713,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -614,6 +1749,130 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index ed7f15ae4..f21b72eee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,9 @@ members = [ "crates/sqlquack-snowflake", "crates/sqlquack-rules", "crates/sqlquack-duckdb", + "crates/sqlquack-policy", "crates/sqlquack-compare", + "crates/sqlquack-evidence", "crates/sqlquack", ] resolver = "2" @@ -20,7 +22,9 @@ publish = false rust-version = "1.88" [workspace.dependencies] +arrow = { version = "60", default-features = false, features = ["ipc"] } clap = { version = "4.5", features = ["derive"] } +duckdb = "=1.20000.0" polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "b18dc647e797e480d2faadf3af407077f8e063df", default-features = false, features = [ "dialect-snowflake", "dialect-duckdb", @@ -33,12 +37,16 @@ polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "b18dc rayon = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" sqlquack-compare = { path = "crates/sqlquack-compare" } sqlquack-core = { path = "crates/sqlquack-core" } sqlquack-duckdb = { path = "crates/sqlquack-duckdb" } +sqlquack-evidence = { path = "crates/sqlquack-evidence" } +sqlquack-policy = { path = "crates/sqlquack-policy" } sqlquack-rules = { path = "crates/sqlquack-rules" } sqlquack-snowflake = { path = "crates/sqlquack-snowflake" } tempfile = "3" +toml = { version = "0.9", default-features = false, features = ["parse", "serde"] } walkdir = "2" [workspace.lints.rust] diff --git a/crates/sqlquack-compare/Cargo.toml b/crates/sqlquack-compare/Cargo.toml index 2414b6ffc..7d267d5ed 100644 --- a/crates/sqlquack-compare/Cargo.toml +++ b/crates/sqlquack-compare/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "sqlquack-compare" version.workspace = true -description = "The strict result comparator for SQLQuack's recorded evidence." +description = "The strict comparator between recorded warehouse results and DuckDB results for SQLQuack." edition.workspace = true license.workspace = true repository.workspace = true @@ -10,6 +10,8 @@ publish.workspace = true rust-version.workspace = true [dependencies] +arrow.workspace = true +sqlquack-policy.workspace = true [lints] workspace = true diff --git a/crates/sqlquack-compare/src/comparison/_helpers/mod.rs b/crates/sqlquack-compare/src/comparison/_helpers/mod.rs new file mode 100644 index 000000000..ed4189bcd --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/_helpers/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod rows; +pub(crate) mod type_names; diff --git a/crates/sqlquack-compare/src/comparison/_helpers/rows.rs b/crates/sqlquack-compare/src/comparison/_helpers/rows.rs new file mode 100644 index 000000000..2b0cf271b --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/_helpers/rows.rs @@ -0,0 +1,116 @@ +//! Row-by-row value comparison. + +use crate::comparison::models::{Mismatch, MismatchKind, RowOrder}; +use crate::values::main::canonical_rows::canonical_rows; +use crate::values::models::{CanonicalValue, ResultSet}; + +pub(crate) struct RowRules { + pub(crate) order: RowOrder, + pub(crate) float32_ulps: u32, + pub(crate) float64_ulps: u32, + pub(crate) ignore_zone_labels: bool, +} + +pub(crate) fn compare_rows( + expected: &ResultSet, + actual: &ResultSet, + rules: &RowRules, +) -> Result<(), Mismatch> { + let mut expected_rows = decoded(expected, rules)?; + let mut actual_rows = decoded(actual, rules)?; + if expected_rows.len() != actual_rows.len() { + return Err(Mismatch { + kind: MismatchKind::RowCount, + detail: format!( + "recorded {} rows, compared result has {}", + expected_rows.len(), + actual_rows.len() + ), + }); + } + if rules.order == RowOrder::Bag { + expected_rows.sort(); + actual_rows.sort(); + } + for (row, (expected_row, actual_row)) in expected_rows.iter().zip(&actual_rows).enumerate() { + for (column, (expected_value, actual_value)) in + expected_row.iter().zip(actual_row).enumerate() + { + if !values_match(expected_value, actual_value, rules) { + return Err(Mismatch { + kind: MismatchKind::Value, + detail: format!( + "row {row}, column {column}: recorded {expected_value:?}, compared {actual_value:?}" + ), + }); + } + } + } + Ok(()) +} + +fn decoded(result: &ResultSet, rules: &RowRules) -> Result>, Mismatch> { + let rows = canonical_rows(result).map_err(|detail| Mismatch { + kind: MismatchKind::Unsupported, + detail, + })?; + if !rules.ignore_zone_labels { + return Ok(rows); + } + Ok(rows + .into_iter() + .map(|row| row.into_iter().map(without_zone_label).collect()) + .collect()) +} + +fn without_zone_label(value: CanonicalValue) -> CanonicalValue { + match value { + CanonicalValue::Timestamp { + nanos, + zone: Some(_), + } => CanonicalValue::Timestamp { + nanos, + zone: Some(String::new()), + }, + other => other, + } +} + +fn values_match(expected: &CanonicalValue, actual: &CanonicalValue, rules: &RowRules) -> bool { + match (expected, actual) { + (CanonicalValue::Float32(left), CanonicalValue::Float32(right)) + if rules.float32_ulps > 0 => + { + ulps_apart(i64::from(ordered32(*left)), i64::from(ordered32(*right))) + <= u64::from(rules.float32_ulps) + } + (CanonicalValue::Float64(left), CanonicalValue::Float64(right)) + if rules.float64_ulps > 0 => + { + ulps_apart(ordered64(*left), ordered64(*right)) <= u64::from(rules.float64_ulps) + } + _ => expected == actual, + } +} + +fn ordered32(bits: u32) -> i32 { + let signed = i32::from_ne_bytes(bits.to_ne_bytes()); + if signed < 0 { + i32::MIN - signed + } else { + signed + } +} + +fn ordered64(bits: u64) -> i64 { + let signed = i64::from_ne_bytes(bits.to_ne_bytes()); + if signed < 0 { + i64::MIN - signed + } else { + signed + } +} + +fn ulps_apart(left: i64, right: i64) -> u64 { + left.abs_diff(right) +} diff --git a/crates/sqlquack-compare/src/comparison/_helpers/type_names.rs b/crates/sqlquack-compare/src/comparison/_helpers/type_names.rs new file mode 100644 index 000000000..f70966ba9 --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/_helpers/type_names.rs @@ -0,0 +1,36 @@ +//! DuckDB type names for the Arrow types DuckDB exports. + +use arrow::datatypes::{DataType, TimeUnit}; + +pub(crate) fn duckdb_type_name(data_type: &DataType) -> String { + match data_type { + DataType::Boolean => "BOOLEAN".to_owned(), + DataType::Int8 => "TINYINT".to_owned(), + DataType::Int16 => "SMALLINT".to_owned(), + DataType::Int32 => "INTEGER".to_owned(), + DataType::Int64 => "BIGINT".to_owned(), + DataType::UInt8 => "UTINYINT".to_owned(), + DataType::UInt16 => "USMALLINT".to_owned(), + DataType::UInt32 => "UINTEGER".to_owned(), + DataType::UInt64 => "UBIGINT".to_owned(), + DataType::Decimal128(precision, scale) => format!("DECIMAL({precision},{scale})"), + DataType::Float32 => "FLOAT".to_owned(), + DataType::Float64 => "DOUBLE".to_owned(), + DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => "VARCHAR".to_owned(), + DataType::Binary | DataType::LargeBinary | DataType::BinaryView => "BLOB".to_owned(), + DataType::Date32 | DataType::Date64 => "DATE".to_owned(), + DataType::Time32(_) | DataType::Time64(_) => "TIME".to_owned(), + DataType::Timestamp(_, Some(_)) => "TIMESTAMPTZ".to_owned(), + DataType::Timestamp(TimeUnit::Second, None) => "TIMESTAMP_S".to_owned(), + DataType::Timestamp(TimeUnit::Millisecond, None) => "TIMESTAMP_MS".to_owned(), + DataType::Timestamp(TimeUnit::Microsecond, None) => "TIMESTAMP".to_owned(), + DataType::Timestamp(TimeUnit::Nanosecond, None) => "TIMESTAMP_NS".to_owned(), + other => format!( + "ARROW_{}", + other + .to_string() + .to_ascii_uppercase() + .replace([' ', '(', ')', ','], "_") + ), + } +} diff --git a/crates/sqlquack-compare/src/comparison/main/compare_recordings.rs b/crates/sqlquack-compare/src/comparison/main/compare_recordings.rs new file mode 100644 index 000000000..f09ce98eb --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/main/compare_recordings.rs @@ -0,0 +1,41 @@ +//! `compare_recordings`: a stored Snowflake result against a fresh recording of the same case. + +use crate::comparison::_helpers::rows::{RowRules, compare_rows}; +use crate::comparison::models::{Mismatch, MismatchKind, RecordedResult, RowOrder}; + +/// Requires identical names, identical Snowflake types and exactly equal values. +pub fn compare_recordings( + stored: &RecordedResult, + fresh: &RecordedResult, + order: RowOrder, +) -> Result<(), Mismatch> { + if stored.columns != fresh.columns { + return Err(Mismatch { + kind: MismatchKind::ColumnType, + detail: format!( + "stored columns {} differ from fresh columns {}", + describe(stored), + describe(fresh) + ), + }); + } + compare_rows( + &stored.rows, + &fresh.rows, + &RowRules { + order, + float32_ulps: 0, + float64_ulps: 0, + ignore_zone_labels: false, + }, + ) +} + +fn describe(result: &RecordedResult) -> String { + result + .columns + .iter() + .map(|column| format!("{} {}", column.name, column.snowflake_type)) + .collect::>() + .join(", ") +} diff --git a/crates/sqlquack-compare/src/comparison/main/compare_with_replay.rs b/crates/sqlquack-compare/src/comparison/main/compare_with_replay.rs new file mode 100644 index 000000000..2e2b19fe4 --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/main/compare_with_replay.rs @@ -0,0 +1,73 @@ +//! `compare_with_replay`: a recorded Snowflake result against the DuckDB replay. + +use sqlquack_policy::policy::main::matching_type_equality::matching_type_equality; +use sqlquack_policy::policy::models::{ + ColumnNameMatching, ComparatorPolicy, TimestampTzComparison, +}; + +use crate::comparison::_helpers::rows::{RowRules, compare_rows}; +use crate::comparison::_helpers::type_names::duckdb_type_name; +use crate::comparison::models::{Mismatch, MismatchKind, RecordedResult, RowOrder}; +use crate::values::models::ResultSet; + +/// Compares names, declared types and values under the policy's comparator settings. +pub fn compare_with_replay( + recorded: &RecordedResult, + replayed: &ResultSet, + order: RowOrder, + comparator: &ComparatorPolicy, +) -> Result<(), Mismatch> { + let fields = replayed.schema.fields(); + if fields.len() != recorded.columns.len() { + return Err(Mismatch { + kind: MismatchKind::ColumnCount, + detail: format!( + "recorded {} columns, replay returned {}", + recorded.columns.len(), + fields.len() + ), + }); + } + for (index, (column, field)) in recorded.columns.iter().zip(fields.iter()).enumerate() { + let names_match = match comparator.column_names { + ColumnNameMatching::Exact => column.name == *field.name(), + ColumnNameMatching::CaseInsensitive => column.name.eq_ignore_ascii_case(field.name()), + }; + if !names_match { + return Err(Mismatch { + kind: MismatchKind::ColumnName, + detail: format!( + "column {index}: recorded `{}`, replay returned `{}`", + column.name, + field.name() + ), + }); + } + let duckdb_type = duckdb_type_name(field.data_type()); + if matching_type_equality( + &comparator.type_equality, + &column.snowflake_type, + &duckdb_type, + ) + .is_none() + { + return Err(Mismatch { + kind: MismatchKind::ColumnType, + detail: format!( + "column `{}`: no declared type equality admits Snowflake {} and DuckDB {duckdb_type}", + column.name, column.snowflake_type + ), + }); + } + } + compare_rows( + &recorded.rows, + replayed, + &RowRules { + order, + float32_ulps: comparator.float_ulps.float32, + float64_ulps: comparator.float_ulps.float64, + ignore_zone_labels: comparator.timestamp_tz == TimestampTzComparison::Instant, + }, + ) +} diff --git a/crates/sqlquack-compare/src/comparison/main/mod.rs b/crates/sqlquack-compare/src/comparison/main/mod.rs new file mode 100644 index 000000000..4fb93ca41 --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/main/mod.rs @@ -0,0 +1,2 @@ +pub mod compare_recordings; +pub mod compare_with_replay; diff --git a/crates/sqlquack-compare/src/comparison/mod.rs b/crates/sqlquack-compare/src/comparison/mod.rs new file mode 100644 index 000000000..7bb4f9a0b --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/mod.rs @@ -0,0 +1,8 @@ +//! Recorded results against replayed or re-recorded results. + +pub(crate) mod _helpers; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlquack-compare/src/comparison/models.rs b/crates/sqlquack-compare/src/comparison/models.rs new file mode 100644 index 000000000..50747af9a --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/models.rs @@ -0,0 +1,64 @@ +//! Comparison inputs and outcomes. + +use std::fmt::{self, Display}; + +use crate::values::models::ResultSet; + +/// A recorded result column: its name and its declared Snowflake type, such as `NUMBER(38,0)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecordedColumn { + /// The result column name. + pub name: String, + /// The Snowflake type as recorded. + pub snowflake_type: String, +} + +/// A recorded warehouse result. +#[derive(Clone, Debug)] +pub struct RecordedResult { + /// The declared columns, in result order. + pub columns: Vec, + /// The values. + pub rows: ResultSet, +} + +/// Whether row order is part of the result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RowOrder { + /// Rows must appear in the same order. + Ordered, + /// Rows compare as a multiset. + Bag, +} + +/// What differs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MismatchKind { + /// The number of columns. + ColumnCount, + /// A column name. + ColumnName, + /// A column type no declared equality admits. + ColumnType, + /// The number of rows. + RowCount, + /// A value. + Value, + /// A value the comparator cannot decode, which never matches. + Unsupported, +} + +/// The first difference found. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Mismatch { + /// What differs. + pub kind: MismatchKind, + /// Where and how. + pub detail: String, +} + +impl Display for Mismatch { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} diff --git a/crates/sqlquack-compare/src/comparison/tests.rs b/crates/sqlquack-compare/src/comparison/tests.rs new file mode 100644 index 000000000..eb60ead4c --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/tests.rs @@ -0,0 +1,8 @@ +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_compare_recordings.rs"] +mod test_compare_recordings; +#[path = "tests/test_compare_with_replay.rs"] +mod test_compare_with_replay; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlquack-compare/src/comparison/tests/helpers.rs b/crates/sqlquack-compare/src/comparison/tests/helpers.rs new file mode 100644 index 000000000..c53387ec7 --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/tests/helpers.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; + +use arrow::array::{ + ArrayRef, Date32Array, Decimal128Array, Float64Array, Int64Array, StringArray, + TimestampMicrosecondArray, TimestampNanosecondArray, +}; +use arrow::datatypes::{Field, Schema}; +use arrow::record_batch::RecordBatch; +use sqlquack_policy::policy::models::{ + ColumnNameMatching, ComparatorPolicy, FloatUlps, NullOrder, TimestampTzComparison, TypeEquality, +}; + +use crate::comparison::main::compare_recordings::compare_recordings; +use crate::comparison::main::compare_with_replay::compare_with_replay; +use crate::comparison::models::{MismatchKind, RecordedColumn, RecordedResult, RowOrder}; +use crate::comparison::tests::test_types::TestColumn; +use crate::values::models::ResultSet; + +pub(crate) fn int64(values: &[Option]) -> ArrayRef { + Arc::new(Int64Array::from(values.to_vec())) +} + +pub(crate) fn decimal(values: &[Option], precision: u8, scale: i8) -> ArrayRef { + Arc::new( + Decimal128Array::from(values.to_vec()) + .with_precision_and_scale(precision, scale) + .expect("valid decimal type"), + ) +} + +pub(crate) fn float64(values: &[Option]) -> ArrayRef { + Arc::new(Float64Array::from(values.to_vec())) +} + +pub(crate) fn text(values: &[Option<&str>]) -> ArrayRef { + Arc::new(StringArray::from(values.to_vec())) +} + +pub(crate) fn date(values: &[Option]) -> ArrayRef { + Arc::new(Date32Array::from(values.to_vec())) +} + +pub(crate) fn timestamp_ns(values: &[Option], zone: &str) -> ArrayRef { + Arc::new(TimestampNanosecondArray::from(values.to_vec()).with_timezone(zone)) +} + +pub(crate) fn timestamp_us(values: &[Option], zone: &str) -> ArrayRef { + Arc::new(TimestampMicrosecondArray::from(values.to_vec()).with_timezone(zone)) +} + +pub(crate) fn column( + name: &'static str, + snowflake_type: &'static str, + values: ArrayRef, +) -> TestColumn { + TestColumn { + name, + snowflake_type, + values, + } +} + +fn result_set(columns: &[TestColumn]) -> ResultSet { + let schema = Arc::new(Schema::new( + columns + .iter() + .map(|column| Field::new(column.name, column.values.data_type().clone(), true)) + .collect::>(), + )); + let batch = RecordBatch::try_new( + schema.clone(), + columns.iter().map(|column| column.values.clone()).collect(), + ) + .expect("test columns have equal lengths"); + ResultSet { + schema, + batches: vec![batch], + } +} + +fn recorded(columns: &[TestColumn]) -> RecordedResult { + RecordedResult { + columns: columns + .iter() + .map(|column| RecordedColumn { + name: column.name.to_owned(), + snowflake_type: column.snowflake_type.to_owned(), + }) + .collect(), + rows: result_set(columns), + } +} + +fn comparator(float64_ulps: u32) -> ComparatorPolicy { + let equality = |snowflake: &str, duckdb: &str| TypeEquality { + snowflake: snowflake.to_owned(), + duckdb: duckdb.to_owned(), + reason: "test".to_owned(), + }; + ComparatorPolicy { + column_names: ColumnNameMatching::CaseInsensitive, + null_order: NullOrder::NullsLastOnAscFirstOnDesc, + timestamp_tz: TimestampTzComparison::Instant, + float_ulps: FloatUlps { + float32: 0, + float64: float64_ulps, + }, + type_equality: vec![ + equality("NUMBER(p,s)", "DECIMAL(p,s)"), + equality("FLOAT", "DOUBLE"), + equality("VARCHAR(n)", "VARCHAR"), + equality("DATE", "DATE"), + equality("TIMESTAMP_TZ(p)", "TIMESTAMPTZ"), + ], + } +} + +pub(crate) fn replay_mismatch( + recorded_columns: &[TestColumn], + replayed_columns: &[TestColumn], + order: RowOrder, + float64_ulps: u32, +) -> Option { + compare_with_replay( + &recorded(recorded_columns), + &result_set(replayed_columns), + order, + &comparator(float64_ulps), + ) + .err() + .map(|mismatch| mismatch.kind) +} + +pub(crate) fn recordings_mismatch( + stored: &[TestColumn], + fresh: &[TestColumn], +) -> Option { + compare_recordings(&recorded(stored), &recorded(fresh), RowOrder::Ordered) + .err() + .map(|mismatch| mismatch.kind) +} diff --git a/crates/sqlquack-compare/src/comparison/tests/test_compare_recordings.rs b/crates/sqlquack-compare/src/comparison/tests/test_compare_recordings.rs new file mode 100644 index 000000000..f0d629d74 --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/tests/test_compare_recordings.rs @@ -0,0 +1,40 @@ +use crate::comparison::models::MismatchKind; +use crate::comparison::tests::helpers::{column, decimal, float64, int64, recordings_mismatch}; +use crate::comparison::tests::test_types::RecordingsTestCase; + +#[test] +fn given_two_recordings_of_a_case_when_auditing_then_any_drift_is_reported() { + let test_cases = [ + RecordingsTestCase { + description: "the same values in another physical type", + stored: vec![column("N", "NUMBER(38,0)", int64(&[Some(7)]))], + fresh: vec![column("N", "NUMBER(38,0)", decimal(&[Some(7)], 38, 0))], + expected_mismatch: None, + }, + RecordingsTestCase { + description: "a changed declared type", + stored: vec![column("N", "NUMBER(38,0)", int64(&[Some(7)]))], + fresh: vec![column("N", "NUMBER(38,2)", decimal(&[Some(700)], 38, 2))], + expected_mismatch: Some(MismatchKind::ColumnType), + }, + RecordingsTestCase { + description: "a float one ulp away", + stored: vec![column("F", "FLOAT", float64(&[Some(0.1)]))], + fresh: vec![column( + "F", + "FLOAT", + float64(&[Some(f64::from_bits(0.1_f64.to_bits() + 1))]), + )], + expected_mismatch: Some(MismatchKind::Value), + }, + ]; + + for test_case in test_cases { + assert_eq!( + recordings_mismatch(&test_case.stored, &test_case.fresh), + test_case.expected_mismatch, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-compare/src/comparison/tests/test_compare_with_replay.rs b/crates/sqlquack-compare/src/comparison/tests/test_compare_with_replay.rs new file mode 100644 index 000000000..8e1b79ac8 --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/tests/test_compare_with_replay.rs @@ -0,0 +1,192 @@ +use crate::comparison::models::{MismatchKind, RowOrder}; +use crate::comparison::tests::helpers::{ + column, date, decimal, float64, int64, replay_mismatch, text, timestamp_ns, timestamp_us, +}; +use crate::comparison::tests::test_types::ReplayTestCase; + +#[test] +fn given_recorded_and_replayed_results_when_comparing_then_only_exact_agreement_matches() { + let test_cases = [ + ReplayTestCase { + description: "an integer recorded as int64 equals the same DECIMAL(38,0) value", + recorded: vec![column("TOTAL", "NUMBER(38,0)", int64(&[Some(3), None]))], + replayed: vec![column("total", "", decimal(&[Some(3), None], 38, 0))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: None, + }, + ReplayTestCase { + description: "a different decimal value", + recorded: vec![column("V", "NUMBER(10,2)", decimal(&[Some(150)], 10, 2))], + replayed: vec![column("v", "", decimal(&[Some(151)], 10, 2))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::Value), + }, + ReplayTestCase { + description: "a scale no declared equality admits", + recorded: vec![column("V", "NUMBER(10,2)", decimal(&[Some(150)], 10, 2))], + replayed: vec![column("v", "", decimal(&[Some(1500)], 10, 3))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::ColumnType), + }, + ReplayTestCase { + description: "NULL is not an empty string", + recorded: vec![column("S", "VARCHAR(16777216)", text(&[None]))], + replayed: vec![column("s", "", text(&[Some("")]))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::Value), + }, + ReplayTestCase { + description: "a renamed column", + recorded: vec![column("S", "VARCHAR(16777216)", text(&[Some("a")]))], + replayed: vec![column("t", "", text(&[Some("a")]))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::ColumnName), + }, + ReplayTestCase { + description: "an extra column", + recorded: vec![column("S", "VARCHAR(16777216)", text(&[Some("a")]))], + replayed: vec![ + column("s", "", text(&[Some("a")])), + column("t", "", text(&[Some("a")])), + ], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::ColumnCount), + }, + ReplayTestCase { + description: "reordered rows fail an ordered comparison", + recorded: vec![column( + "S", + "VARCHAR(16777216)", + text(&[Some("a"), Some("b")]), + )], + replayed: vec![column("s", "", text(&[Some("b"), Some("a")]))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::Value), + }, + ReplayTestCase { + description: "reordered rows pass a bag comparison", + recorded: vec![column( + "S", + "VARCHAR(16777216)", + text(&[Some("a"), Some("b")]), + )], + replayed: vec![column("s", "", text(&[Some("b"), Some("a")]))], + order: RowOrder::Bag, + float64_ulps: 0, + expected_mismatch: None, + }, + ReplayTestCase { + description: "a missing row", + recorded: vec![column( + "S", + "VARCHAR(16777216)", + text(&[Some("a"), Some("b")]), + )], + replayed: vec![column("s", "", text(&[Some("a")]))], + order: RowOrder::Bag, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::RowCount), + }, + ReplayTestCase { + description: "floats compare bitwise, so negative zero differs from zero", + recorded: vec![column("F", "FLOAT", float64(&[Some(0.0)]))], + replayed: vec![column("f", "", float64(&[Some(-0.0)]))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::Value), + }, + ReplayTestCase { + description: "every NaN is one value", + recorded: vec![column("F", "FLOAT", float64(&[Some(f64::NAN)]))], + replayed: vec![column("f", "", float64(&[Some(-f64::NAN)]))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: None, + }, + ReplayTestCase { + description: "one ulp apart fails without tolerance", + recorded: vec![column("F", "FLOAT", float64(&[Some(0.1)]))], + replayed: vec![column( + "f", + "", + float64(&[Some(f64::from_bits(0.1_f64.to_bits() + 1))]), + )], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::Value), + }, + ReplayTestCase { + description: "one ulp apart passes a declared one-ulp tolerance", + recorded: vec![column("F", "FLOAT", float64(&[Some(0.1)]))], + replayed: vec![column( + "f", + "", + float64(&[Some(f64::from_bits(0.1_f64.to_bits() + 1))]), + )], + order: RowOrder::Ordered, + float64_ulps: 1, + expected_mismatch: None, + }, + ReplayTestCase { + description: "the same instant at different precisions and zone labels", + recorded: vec![column( + "T", + "TIMESTAMP_TZ(9)", + timestamp_ns(&[Some(1_700_000_000_123_456_000)], "+00:00"), + )], + replayed: vec![column( + "t", + "", + timestamp_us(&[Some(1_700_000_000_123_456)], "UTC"), + )], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: None, + }, + ReplayTestCase { + description: "a sub-microsecond difference is a value difference", + recorded: vec![column( + "T", + "TIMESTAMP_TZ(9)", + timestamp_ns(&[Some(1_700_000_000_123_456_789)], "UTC"), + )], + replayed: vec![column( + "t", + "", + timestamp_us(&[Some(1_700_000_000_123_456)], "UTC"), + )], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::Value), + }, + ReplayTestCase { + description: "a date against text is a type difference", + recorded: vec![column("D", "DATE", date(&[Some(19_000)]))], + replayed: vec![column("d", "", text(&[Some("2022-01-08")]))], + order: RowOrder::Ordered, + float64_ulps: 0, + expected_mismatch: Some(MismatchKind::ColumnType), + }, + ]; + + for test_case in test_cases { + assert_eq!( + replay_mismatch( + &test_case.recorded, + &test_case.replayed, + test_case.order, + test_case.float64_ulps + ), + test_case.expected_mismatch, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-compare/src/comparison/tests/test_types.rs b/crates/sqlquack-compare/src/comparison/tests/test_types.rs new file mode 100644 index 000000000..a7ca5faf8 --- /dev/null +++ b/crates/sqlquack-compare/src/comparison/tests/test_types.rs @@ -0,0 +1,26 @@ +use arrow::array::ArrayRef; + +use crate::comparison::models::{MismatchKind, RowOrder}; + +/// One result column for a test: name, declared or exported type, and values. +pub(crate) struct TestColumn { + pub(crate) name: &'static str, + pub(crate) snowflake_type: &'static str, + pub(crate) values: ArrayRef, +} + +pub(crate) struct ReplayTestCase { + pub(crate) description: &'static str, + pub(crate) recorded: Vec, + pub(crate) replayed: Vec, + pub(crate) order: RowOrder, + pub(crate) float64_ulps: u32, + pub(crate) expected_mismatch: Option, +} + +pub(crate) struct RecordingsTestCase { + pub(crate) description: &'static str, + pub(crate) stored: Vec, + pub(crate) fresh: Vec, + pub(crate) expected_mismatch: Option, +} diff --git a/crates/sqlquack-compare/src/lib.rs b/crates/sqlquack-compare/src/lib.rs index 59580133a..f5116b9a9 100644 --- a/crates/sqlquack-compare/src/lib.rs +++ b/crates/sqlquack-compare/src/lib.rs @@ -1,3 +1,6 @@ -//! The strict comparator for recorded warehouse results; a stub until recorded evidence exists. +//! The strict comparator: exact decimals, bitwise floats, nanosecond timestamps and declared types. #![forbid(unsafe_code)] + +pub mod comparison; +pub mod values; diff --git a/crates/sqlquack-compare/src/values/_helpers/decode.rs b/crates/sqlquack-compare/src/values/_helpers/decode.rs new file mode 100644 index 000000000..f40142da6 --- /dev/null +++ b/crates/sqlquack-compare/src/values/_helpers/decode.rs @@ -0,0 +1,233 @@ +//! Arrow arrays to canonical values. + +use arrow::array::{Array, AsArray}; +use arrow::datatypes::{ + DataType, Date32Type, Date64Type, Decimal128Type, Float32Type, Float64Type, Int8Type, + Int16Type, Int32Type, Int64Type, Time32MillisecondType, Time32SecondType, + Time64MicrosecondType, Time64NanosecondType, TimeUnit, TimestampMicrosecondType, + TimestampMillisecondType, TimestampNanosecondType, TimestampSecondType, UInt8Type, UInt16Type, + UInt32Type, UInt64Type, +}; + +use crate::values::models::CanonicalValue; + +const MILLIS_PER_DAY: i64 = 86_400_000; + +pub(crate) fn column_values(array: &dyn Array) -> Result, String> { + let values: Vec> = match array.data_type() { + DataType::Null => vec![None; array.len()], + DataType::Boolean => array + .as_boolean() + .iter() + .map(|value| value.map(CanonicalValue::Boolean)) + .collect(), + DataType::Int8 => integers( + array + .as_primitive::() + .iter() + .map(|value| value.map(i128::from)), + ), + DataType::Int16 => integers( + array + .as_primitive::() + .iter() + .map(|value| value.map(i128::from)), + ), + DataType::Int32 => integers( + array + .as_primitive::() + .iter() + .map(|value| value.map(i128::from)), + ), + DataType::Int64 => integers( + array + .as_primitive::() + .iter() + .map(|value| value.map(i128::from)), + ), + DataType::UInt8 => integers( + array + .as_primitive::() + .iter() + .map(|value| value.map(i128::from)), + ), + DataType::UInt16 => integers( + array + .as_primitive::() + .iter() + .map(|value| value.map(i128::from)), + ), + DataType::UInt32 => integers( + array + .as_primitive::() + .iter() + .map(|value| value.map(i128::from)), + ), + DataType::UInt64 => integers( + array + .as_primitive::() + .iter() + .map(|value| value.map(i128::from)), + ), + DataType::Decimal128(_, scale) => array + .as_primitive::() + .iter() + .map(|value| value.map(|unscaled| number(unscaled, *scale))) + .collect(), + DataType::Float32 => array + .as_primitive::() + .iter() + .map(|value| value.map(float32)) + .collect(), + DataType::Float64 => array + .as_primitive::() + .iter() + .map(|value| value.map(float64)) + .collect(), + DataType::Utf8 => texts(array.as_string::().iter()), + DataType::LargeUtf8 => texts(array.as_string::().iter()), + DataType::Utf8View => texts(array.as_string_view().iter()), + DataType::Binary => binaries(array.as_binary::().iter()), + DataType::LargeBinary => binaries(array.as_binary::().iter()), + DataType::BinaryView => binaries(array.as_binary_view().iter()), + DataType::FixedSizeBinary(_) => binaries(array.as_fixed_size_binary().iter()), + DataType::Date32 => array + .as_primitive::() + .iter() + .map(|value| value.map(|days| CanonicalValue::Date(i64::from(days)))) + .collect(), + DataType::Date64 => array + .as_primitive::() + .iter() + .map(|value| { + value.map(|millis| CanonicalValue::Date(millis.div_euclid(MILLIS_PER_DAY))) + }) + .collect(), + DataType::Time32(TimeUnit::Second) => times( + array + .as_primitive::() + .iter() + .map(|value| value.map(i64::from)), + 1_000_000_000, + ), + DataType::Time32(TimeUnit::Millisecond) => times( + array + .as_primitive::() + .iter() + .map(|value| value.map(i64::from)), + 1_000_000, + ), + DataType::Time64(TimeUnit::Microsecond) => { + times(array.as_primitive::().iter(), 1_000) + } + DataType::Time64(TimeUnit::Nanosecond) => { + times(array.as_primitive::().iter(), 1) + } + DataType::Timestamp(unit, zone) => { + let zone = zone.as_ref().map(ToString::to_string); + let (raw, factor): (Vec>, i128) = match unit { + TimeUnit::Second => ( + array.as_primitive::().iter().collect(), + 1_000_000_000, + ), + TimeUnit::Millisecond => ( + array + .as_primitive::() + .iter() + .collect(), + 1_000_000, + ), + TimeUnit::Microsecond => ( + array + .as_primitive::() + .iter() + .collect(), + 1_000, + ), + TimeUnit::Nanosecond => ( + array + .as_primitive::() + .iter() + .collect(), + 1, + ), + }; + raw.into_iter() + .map(|value| { + value.map(|ticks| CanonicalValue::Timestamp { + nanos: i128::from(ticks) * factor, + zone: zone.clone(), + }) + }) + .collect() + } + other => { + return Err(format!( + "values of Arrow type {other} have no canonical form" + )); + } + }; + Ok(values + .into_iter() + .map(|value| value.unwrap_or(CanonicalValue::Null)) + .collect()) +} + +pub(crate) fn number(unscaled: i128, scale: i8) -> CanonicalValue { + let mut unscaled = unscaled; + let mut scale = scale; + while scale > 0 && unscaled % 10 == 0 { + unscaled /= 10; + scale -= 1; + } + while scale < 0 { + match unscaled.checked_mul(10) { + Some(widened) => { + unscaled = widened; + scale += 1; + } + None => break, + } + } + CanonicalValue::Number { unscaled, scale } +} + +fn integers(values: impl Iterator>) -> Vec> { + values + .map(|value| value.map(|unscaled| number(unscaled, 0))) + .collect() +} + +fn float32(value: f32) -> CanonicalValue { + CanonicalValue::Float32(if value.is_nan() { + f32::NAN.to_bits() + } else { + value.to_bits() + }) +} + +fn float64(value: f64) -> CanonicalValue { + CanonicalValue::Float64(if value.is_nan() { + f64::NAN.to_bits() + } else { + value.to_bits() + }) +} + +fn texts<'a>(values: impl Iterator>) -> Vec> { + values + .map(|value| value.map(|text| CanonicalValue::Text(text.to_owned()))) + .collect() +} + +fn binaries<'a>(values: impl Iterator>) -> Vec> { + values + .map(|value| value.map(|bytes| CanonicalValue::Binary(bytes.to_vec()))) + .collect() +} + +fn times(values: impl Iterator>, factor: i64) -> Vec> { + values + .map(|value| value.map(|ticks| CanonicalValue::Time(ticks * factor))) + .collect() +} diff --git a/crates/sqlquack-compare/src/values/_helpers/mod.rs b/crates/sqlquack-compare/src/values/_helpers/mod.rs new file mode 100644 index 000000000..d19e9f278 --- /dev/null +++ b/crates/sqlquack-compare/src/values/_helpers/mod.rs @@ -0,0 +1 @@ +pub(crate) mod decode; diff --git a/crates/sqlquack-compare/src/values/main/canonical_rows.rs b/crates/sqlquack-compare/src/values/main/canonical_rows.rs new file mode 100644 index 000000000..0320c2ae8 --- /dev/null +++ b/crates/sqlquack-compare/src/values/main/canonical_rows.rs @@ -0,0 +1,20 @@ +//! `canonical_rows`: every row of a result set as canonical values. + +use crate::values::_helpers::decode::column_values; +use crate::values::models::{CanonicalValue, ResultSet}; + +/// The rows of `result`, or the first column whose Arrow type has no canonical form. +pub fn canonical_rows(result: &ResultSet) -> Result>, String> { + let mut rows: Vec> = Vec::new(); + for batch in &result.batches { + let columns = batch + .columns() + .iter() + .map(|column| column_values(column.as_ref())) + .collect::, String>>()?; + for row in 0..batch.num_rows() { + rows.push(columns.iter().map(|column| column[row].clone()).collect()); + } + } + Ok(rows) +} diff --git a/crates/sqlquack-compare/src/values/main/mod.rs b/crates/sqlquack-compare/src/values/main/mod.rs new file mode 100644 index 000000000..e0fc06683 --- /dev/null +++ b/crates/sqlquack-compare/src/values/main/mod.rs @@ -0,0 +1 @@ +pub mod canonical_rows; diff --git a/crates/sqlquack-compare/src/values/mod.rs b/crates/sqlquack-compare/src/values/mod.rs new file mode 100644 index 000000000..01499e8f8 --- /dev/null +++ b/crates/sqlquack-compare/src/values/mod.rs @@ -0,0 +1,5 @@ +//! Canonical cell values decoded from Arrow arrays. + +pub(crate) mod _helpers; +pub mod main; +pub mod models; diff --git a/crates/sqlquack-compare/src/values/models.rs b/crates/sqlquack-compare/src/values/models.rs new file mode 100644 index 000000000..a6d404f35 --- /dev/null +++ b/crates/sqlquack-compare/src/values/models.rs @@ -0,0 +1,48 @@ +//! Canonical values and result sets. + +use arrow::datatypes::SchemaRef; +use arrow::record_batch::RecordBatch; + +/// One cell in a physical-type-independent form; equal values are structurally equal. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum CanonicalValue { + /// SQL NULL. + Null, + /// A boolean. + Boolean(bool), + /// An exact number with trailing zeros of the fraction removed. + Number { + /// The digits as an integer. + unscaled: i128, + /// Digits after the decimal point. + scale: i8, + }, + /// A 32-bit float's bits; every NaN has one representation. + Float32(u32), + /// A 64-bit float's bits; every NaN has one representation. + Float64(u64), + /// Text. + Text(String), + /// Bytes. + Binary(Vec), + /// Days since 1970-01-01. + Date(i64), + /// Nanoseconds since midnight. + Time(i64), + /// Nanoseconds since 1970-01-01 00:00, and the zone label of zoned timestamps. + Timestamp { + /// Nanoseconds since the epoch, in UTC for zoned timestamps. + nanos: i128, + /// The Arrow zone label; absent for naive timestamps. + zone: Option, + }, +} + +/// A query result: its Arrow schema and batches. +#[derive(Clone, Debug)] +pub struct ResultSet { + /// The schema, present even without rows. + pub schema: SchemaRef, + /// The rows. + pub batches: Vec, +} diff --git a/crates/sqlquack-evidence/Cargo.toml b/crates/sqlquack-evidence/Cargo.toml new file mode 100644 index 000000000..53c718c95 --- /dev/null +++ b/crates/sqlquack-evidence/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "sqlquack-evidence" +version.workspace = true +description = "SQLQuack evidence: cases, content hashes, pending files, recorded results, DuckDB replay and audits." +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +arrow.workspace = true +duckdb.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +sqlquack-compare.workspace = true +sqlquack-core.workspace = true +sqlquack-policy.workspace = true +toml.workspace = true +walkdir.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/sqlquack-evidence/src/lib.rs b/crates/sqlquack-evidence/src/lib.rs new file mode 100644 index 000000000..179adb7b4 --- /dev/null +++ b/crates/sqlquack-evidence/src/lib.rs @@ -0,0 +1 @@ +//! placeholder diff --git a/crates/sqlquack-policy/Cargo.toml b/crates/sqlquack-policy/Cargo.toml new file mode 100644 index 000000000..50c3b2cb1 --- /dev/null +++ b/crates/sqlquack-policy/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sqlquack-policy" +version.workspace = true +description = "The SQLQuack evidence policy file: comparator settings, session profiles, edge-case minimums and known divergences." +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +serde.workspace = true +toml.workspace = true + +[lints] +workspace = true diff --git a/crates/sqlquack-policy/src/lib.rs b/crates/sqlquack-policy/src/lib.rs new file mode 100644 index 000000000..c0a816e02 --- /dev/null +++ b/crates/sqlquack-policy/src/lib.rs @@ -0,0 +1,5 @@ +//! The one policy file holding every setting that could be weakened to make a failing case pass. + +#![forbid(unsafe_code)] + +pub mod policy; diff --git a/crates/sqlquack-policy/src/policy/_helpers/mod.rs b/crates/sqlquack-policy/src/policy/_helpers/mod.rs new file mode 100644 index 000000000..08103424e --- /dev/null +++ b/crates/sqlquack-policy/src/policy/_helpers/mod.rs @@ -0,0 +1 @@ +pub(crate) mod validation; diff --git a/crates/sqlquack-policy/src/policy/_helpers/validation.rs b/crates/sqlquack-policy/src/policy/_helpers/validation.rs new file mode 100644 index 000000000..860ec6e86 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/_helpers/validation.rs @@ -0,0 +1,184 @@ +//! Policy rules beyond the schema. + +use std::collections::BTreeSet; + +use crate::policy::constants::SCHEMA_VERSION; +use crate::policy::main::parse_type_pattern::parse_type_pattern; +use crate::policy::models::{ + Divergence, EdgeCaseRequirement, PatternArgument, Policy, SessionProfile, TypeEquality, + TypePattern, +}; + +pub(crate) fn problems(policy: &Policy) -> Vec { + let mut found: Vec = Vec::new(); + if policy.schema_version != SCHEMA_VERSION { + found.push(format!( + "schema_version is {}, but this build reads version {SCHEMA_VERSION}", + policy.schema_version + )); + } + found.extend( + policy + .comparator + .type_equality + .iter() + .flat_map(equality_problems), + ); + if policy.session_profiles.is_empty() { + found.push("session_profiles declares no profile".to_owned()); + } + found.extend( + policy + .session_profiles + .iter() + .flat_map(|(name, profile)| profile_problems(name, profile)), + ); + found.extend( + policy + .edge_cases + .iter() + .flat_map(|(name, requirement)| edge_case_problems(name, requirement)), + ); + found.extend( + policy + .divergences + .iter() + .flat_map(|(name, divergence)| divergence_problems(name, divergence)), + ); + found +} + +fn variables(pattern: &TypePattern) -> BTreeSet<&str> { + pattern + .arguments + .iter() + .filter_map(|argument| match argument { + PatternArgument::Variable(name) => Some(name.as_str()), + PatternArgument::Literal(_) => None, + }) + .collect() +} + +fn equality_problems(equality: &TypeEquality) -> Vec { + let label = format!( + "type_equality `{}` = `{}`", + equality.snowflake, equality.duckdb + ); + let mut found: Vec = Vec::new(); + if equality.reason.trim().is_empty() { + found.push(format!("{label} has no reason")); + } + match ( + parse_type_pattern(&equality.snowflake), + parse_type_pattern(&equality.duckdb), + ) { + (Ok(snowflake), Ok(duckdb)) => { + let bound = variables(&snowflake); + let unbound: Vec<&str> = variables(&duckdb) + .into_iter() + .filter(|name| !bound.contains(name)) + .collect(); + if !unbound.is_empty() { + found.push(format!( + "{label} uses parameters the Snowflake side does not bind: {}", + unbound.join(", ") + )); + } + } + (snowflake, duckdb) => { + found.extend(snowflake.err().map(|error| format!("{label}: {error}"))); + found.extend(duckdb.err().map(|error| format!("{label}: {error}"))); + } + } + found +} + +fn profile_problems(name: &str, profile: &SessionProfile) -> Vec { + let mut found: Vec = Vec::new(); + if !is_kebab_name(name) { + found.push(format!( + "session profile `{name}` needs a lower-case kebab name" + )); + } + if profile.description.trim().is_empty() { + found.push(format!("session profile `{name}` has no description")); + } + if profile.duckdb_time_zone.trim().is_empty() { + found.push(format!("session profile `{name}` has no duckdb_time_zone")); + } + found.extend( + profile + .snowflake + .keys() + .filter(|key| !is_parameter_name(key)) + .map(|key| format!("session profile `{name}` has an invalid parameter name `{key}`")), + ); + found +} + +fn edge_case_problems(name: &str, requirement: &EdgeCaseRequirement) -> Vec { + let mut found: Vec = Vec::new(); + if !is_kebab_name(name) { + found.push(format!( + "edge-case category `{name}` needs a lower-case kebab name" + )); + } + if requirement.description.trim().is_empty() { + found.push(format!("edge-case category `{name}` has no description")); + } + if requirement.required.is_empty() { + found.push(format!("edge-case category `{name}` requires no input")); + } + let unique: BTreeSet<&String> = requirement.required.iter().collect(); + if unique.len() != requirement.required.len() { + found.push(format!( + "edge-case category `{name}` repeats a required input" + )); + } + found +} + +fn divergence_problems(name: &str, divergence: &Divergence) -> Vec { + let mut found: Vec = Vec::new(); + if divergence.constructs.is_empty() { + found.push(format!("divergence `{name}` names no construct")); + } + found.extend( + divergence + .constructs + .iter() + .filter(|construct| !is_construct_key(construct)) + .map(|construct| { + format!("divergence `{name}` has an invalid construct key `{construct}`") + }), + ); + if divergence.strict < divergence.default { + found.push(format!( + "divergence `{name}` is treated more leniently in strict mode" + )); + } + if divergence.reason.trim().is_empty() { + found.push(format!("divergence `{name}` has no reason")); + } + found +} + +fn is_kebab_name(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) +} + +fn is_parameter_name(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }) +} + +fn is_construct_key(key: &str) -> bool { + const FAMILIES: [&str; 6] = ["node", "fn", "cast", "join", "clause", "syntax"]; + key.split_once(':') + .is_some_and(|(family, name)| FAMILIES.contains(&family) && !name.is_empty()) +} diff --git a/crates/sqlquack-policy/src/policy/constants.rs b/crates/sqlquack-policy/src/policy/constants.rs new file mode 100644 index 000000000..9c151de4c --- /dev/null +++ b/crates/sqlquack-policy/src/policy/constants.rs @@ -0,0 +1,7 @@ +//! Policy file identity. + +/// The policy file at the repository root. +pub const POLICY_FILE_NAME: &str = "sqlquack.toml"; + +/// The only schema version this build understands. +pub const SCHEMA_VERSION: u32 = 1; diff --git a/crates/sqlquack-policy/src/policy/errors.rs b/crates/sqlquack-policy/src/policy/errors.rs new file mode 100644 index 000000000..c79c61e8e --- /dev/null +++ b/crates/sqlquack-policy/src/policy/errors.rs @@ -0,0 +1,34 @@ +//! Policy failures. + +use std::fmt::{self, Display}; + +/// Why a policy file was rejected. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PolicyError { + /// The file could not be read. + Read(String), + /// The file is not valid TOML for the schema, including unknown keys. + Schema(String), + /// The file parsed but breaks a validation rule; every broken rule is listed. + Invalid(Vec), +} + +impl Display for PolicyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read(message) => write!(formatter, "cannot read the policy file: {message}"), + Self::Schema(message) => { + write!(formatter, "the policy file breaks its schema: {message}") + } + Self::Invalid(problems) => { + write!( + formatter, + "the policy file is invalid: {}", + problems.join("; ") + ) + } + } + } +} + +impl std::error::Error for PolicyError {} diff --git a/crates/sqlquack-policy/src/policy/main/load_policy.rs b/crates/sqlquack-policy/src/policy/main/load_policy.rs new file mode 100644 index 000000000..0d2bb69d6 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/main/load_policy.rs @@ -0,0 +1,14 @@ +//! `load_policy`: read and validate a policy file. + +use std::path::Path; + +use crate::policy::errors::PolicyError; +use crate::policy::main::parse_policy::parse_policy; +use crate::policy::models::Policy; + +/// Reads `path` and validates it against the schema and every policy rule. +pub fn load_policy(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|error| PolicyError::Read(format!("{}: {error}", path.display())))?; + parse_policy(&text) +} diff --git a/crates/sqlquack-policy/src/policy/main/matching_type_equality.rs b/crates/sqlquack-policy/src/policy/main/matching_type_equality.rs new file mode 100644 index 000000000..da9eedd81 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/main/matching_type_equality.rs @@ -0,0 +1,50 @@ +//! `matching_type_equality`: the declared equality that admits a pair of result types. + +use std::collections::BTreeMap; + +use crate::policy::main::parse_type_pattern::parse_type_pattern; +use crate::policy::models::{PatternArgument, TypeEquality, TypePattern}; + +/// The first declared equality whose patterns match `snowflake` and `duckdb` with one binding. +pub fn matching_type_equality<'a>( + equalities: &'a [TypeEquality], + snowflake: &str, + duckdb: &str, +) -> Option<&'a TypeEquality> { + let snowflake = parse_type_pattern(snowflake).ok()?; + let duckdb = parse_type_pattern(duckdb).ok()?; + equalities + .iter() + .find(|equality| admits(equality, &snowflake, &duckdb)) +} + +fn admits(equality: &TypeEquality, snowflake: &TypePattern, duckdb: &TypePattern) -> bool { + let (Ok(snowflake_pattern), Ok(duckdb_pattern)) = ( + parse_type_pattern(&equality.snowflake), + parse_type_pattern(&equality.duckdb), + ) else { + return false; + }; + let mut bindings: BTreeMap = BTreeMap::new(); + bind(&snowflake_pattern, snowflake, &mut bindings) + && bind(&duckdb_pattern, duckdb, &mut bindings) +} + +fn bind(pattern: &TypePattern, actual: &TypePattern, bindings: &mut BTreeMap) -> bool { + if pattern.base != actual.base || pattern.arguments.len() != actual.arguments.len() { + return false; + } + pattern + .arguments + .iter() + .zip(&actual.arguments) + .all(|(expected, found)| match (expected, found) { + (PatternArgument::Literal(expected), PatternArgument::Literal(found)) => { + expected == found + } + (PatternArgument::Variable(name), PatternArgument::Literal(found)) => { + *bindings.entry(name.clone()).or_insert(*found) == *found + } + _ => false, + }) +} diff --git a/crates/sqlquack-policy/src/policy/main/mod.rs b/crates/sqlquack-policy/src/policy/main/mod.rs new file mode 100644 index 000000000..4daa1bce7 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/main/mod.rs @@ -0,0 +1,4 @@ +pub mod load_policy; +pub mod matching_type_equality; +pub mod parse_policy; +pub mod parse_type_pattern; diff --git a/crates/sqlquack-policy/src/policy/main/parse_policy.rs b/crates/sqlquack-policy/src/policy/main/parse_policy.rs new file mode 100644 index 000000000..54e4dd646 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/main/parse_policy.rs @@ -0,0 +1,17 @@ +//! `parse_policy`: policy text to a validated policy. + +use crate::policy::_helpers::validation::problems; +use crate::policy::errors::PolicyError; +use crate::policy::models::Policy; + +/// Parses policy TOML, rejecting unknown keys, then checks every validation rule. +pub fn parse_policy(text: &str) -> Result { + let policy: Policy = + toml::from_str(text).map_err(|error| PolicyError::Schema(error.to_string()))?; + let found = problems(&policy); + if found.is_empty() { + Ok(policy) + } else { + Err(PolicyError::Invalid(found)) + } +} diff --git a/crates/sqlquack-policy/src/policy/main/parse_type_pattern.rs b/crates/sqlquack-policy/src/policy/main/parse_type_pattern.rs new file mode 100644 index 000000000..5be7106bf --- /dev/null +++ b/crates/sqlquack-policy/src/policy/main/parse_type_pattern.rs @@ -0,0 +1,53 @@ +//! `parse_type_pattern`: `NUMBER(p,s)` and similar to a pattern. + +use crate::policy::models::{PatternArgument, TypePattern}; + +/// Parses a type name with optional parameters; lower-case parameters are variables. +pub fn parse_type_pattern(text: &str) -> Result { + let text = text.trim(); + let (base, rest) = match text.split_once('(') { + Some((base, rest)) => (base.trim(), Some(rest)), + None => (text, None), + }; + if base.is_empty() + || !base.chars().all(|character| { + character.is_ascii_uppercase() || character == '_' || character.is_ascii_digit() + }) + { + return Err(format!( + "type pattern `{text}` needs an upper-case type name" + )); + } + let arguments = match rest { + None => Vec::new(), + Some(rest) => { + let inner = rest + .strip_suffix(')') + .ok_or_else(|| format!("type pattern `{text}` has an unclosed parenthesis"))?; + inner + .split(',') + .map(|argument| parse_argument(argument.trim(), text)) + .collect::, String>>()? + } + }; + Ok(TypePattern { + base: base.to_owned(), + arguments, + }) +} + +fn parse_argument(argument: &str, text: &str) -> Result { + if let Ok(number) = argument.parse::() { + return Ok(PatternArgument::Literal(number)); + } + if !argument.is_empty() + && argument + .chars() + .all(|character| character.is_ascii_lowercase() || character == '_') + { + return Ok(PatternArgument::Variable(argument.to_owned())); + } + Err(format!( + "type pattern `{text}` has an invalid parameter `{argument}`" + )) +} diff --git a/crates/sqlquack-policy/src/policy/mod.rs b/crates/sqlquack-policy/src/policy/mod.rs new file mode 100644 index 000000000..da9999ebe --- /dev/null +++ b/crates/sqlquack-policy/src/policy/mod.rs @@ -0,0 +1,10 @@ +//! Loading and validating `sqlquack.toml`. + +pub(crate) mod _helpers; +pub mod constants; +pub mod errors; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlquack-policy/src/policy/models.rs b/crates/sqlquack-policy/src/policy/models.rs new file mode 100644 index 000000000..6b3bb3f20 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/models.rs @@ -0,0 +1,182 @@ +//! The policy schema. Every struct rejects unknown keys. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// The whole policy file. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Policy { + /// The schema version; see `SCHEMA_VERSION`. + pub schema_version: u32, + /// How recorded and replayed results are compared. + pub comparator: ComparatorPolicy, + /// Session settings cases are recorded and replayed under, by profile name. + pub session_profiles: BTreeMap, + /// The edge-case input tags every case file of a category must cover, by category. + pub edge_cases: BTreeMap, + /// Constructs whose source values DuckDB cannot reproduce but whose behaviour is consistent. + #[serde(default)] + pub divergences: BTreeMap, +} + +/// Comparator settings. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComparatorPolicy { + /// How result column names are matched. + pub column_names: ColumnNameMatching, + /// DuckDB's `default_null_order` during replay. + pub null_order: NullOrder, + /// How zoned timestamps compare. + pub timestamp_tz: TimestampTzComparison, + /// Allowed distance in units in the last place, by replayed float type. + pub float_ulps: FloatUlps, + /// Snowflake and DuckDB result types that count as equal. + pub type_equality: Vec, +} + +/// How result column names are matched. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColumnNameMatching { + /// Names must be byte-identical. + Exact, + /// Names must be equal after ASCII upper-casing; Snowflake upper-cases unquoted names. + CaseInsensitive, +} + +/// DuckDB's `default_null_order` values. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NullOrder { + /// NULLs first in both directions. + NullsFirst, + /// NULLs last in both directions. + NullsLast, + /// NULLs first ascending, last descending. + NullsFirstOnAscLastOnDesc, + /// NULLs last ascending, first descending. + NullsLastOnAscFirstOnDesc, +} + +impl NullOrder { + /// The DuckDB setting value. + pub fn duckdb_setting(self) -> &'static str { + match self { + Self::NullsFirst => "nulls_first", + Self::NullsLast => "nulls_last", + Self::NullsFirstOnAscLastOnDesc => "nulls_first_on_asc_last_on_desc", + Self::NullsLastOnAscFirstOnDesc => "nulls_last_on_asc_first_on_desc", + } + } +} + +/// How zoned timestamps compare. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TimestampTzComparison { + /// The same instant. + Instant, + /// The same instant and the same zone label. + InstantAndZone, +} + +/// Allowed float distance in units in the last place. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FloatUlps { + /// For 32-bit replayed floats. + pub float32: u32, + /// For 64-bit replayed floats. + pub float64: u32, +} + +/// A Snowflake result type and the DuckDB result type that counts as equal to it. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TypeEquality { + /// A Snowflake type pattern such as `NUMBER(p,s)`; lower-case names bind parameters. + pub snowflake: String, + /// A DuckDB type pattern such as `DECIMAL(p,s)`, using only names the Snowflake side binds. + pub duckdb: String, + /// Why the two types hold the same values. + pub reason: String, +} + +/// Session settings for recording and replay. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionProfile { + /// What the profile is for. + pub description: String, + /// Snowflake session parameters set while recording; part of every case hash. + pub snowflake: BTreeMap, + /// DuckDB's `TimeZone` during replay. + pub duckdb_time_zone: String, +} + +/// A session parameter value. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum SessionValue { + /// A boolean parameter. + Boolean(bool), + /// An integer parameter. + Integer(i64), + /// A text parameter. + Text(String), +} + +/// The input tags a category's case files must cover between them. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EdgeCaseRequirement { + /// What the category covers. + pub description: String, + /// Tags that at least one case of every case file must carry. + pub required: Vec, +} + +/// A known divergence and how each mode treats it. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Divergence { + /// Construct keys such as `fn:HASH`. + pub constructs: Vec, + /// Treatment in the default mode. + pub default: DivergenceMode, + /// Treatment in strict mode. + pub strict: DivergenceMode, + /// Why values differ and what stays consistent. + pub reason: String, +} + +/// How a mode treats a known divergence. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DivergenceMode { + /// Convert with a warning naming the location and the reason. + Warn, + /// Refuse. + Refuse, +} + +/// A parsed type pattern such as `NUMBER(p,s)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypePattern { + /// The upper-case type name. + pub base: String, + /// The parameters. + pub arguments: Vec, +} + +/// One type-pattern parameter. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PatternArgument { + /// A fixed number. + Literal(i64), + /// A lower-case name bound by the first match. + Variable(String), +} diff --git a/crates/sqlquack-policy/src/policy/tests.rs b/crates/sqlquack-policy/src/policy/tests.rs new file mode 100644 index 000000000..2d11eda89 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/tests.rs @@ -0,0 +1,8 @@ +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_parse_policy.rs"] +mod test_parse_policy; +#[path = "tests/test_type_equality.rs"] +mod test_type_equality; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlquack-policy/src/policy/tests/helpers.rs b/crates/sqlquack-policy/src/policy/tests/helpers.rs new file mode 100644 index 000000000..ab470a0fd --- /dev/null +++ b/crates/sqlquack-policy/src/policy/tests/helpers.rs @@ -0,0 +1,65 @@ +use crate::policy::main::matching_type_equality::matching_type_equality; +use crate::policy::main::parse_policy::parse_policy; +use crate::policy::models::Policy; + +pub(crate) const VALID_POLICY: &str = r#" +schema_version = 1 + +[comparator] +column_names = "case_insensitive" +null_order = "nulls_last_on_asc_first_on_desc" +timestamp_tz = "instant" + +[comparator.float_ulps] +float32 = 0 +float64 = 0 + +[[comparator.type_equality]] +snowflake = "NUMBER(p,s)" +duckdb = "DECIMAL(p,s)" +reason = "same values" + +[[comparator.type_equality]] +snowflake = "VARCHAR(n)" +duckdb = "VARCHAR" +reason = "length does not change values" + +[session_profiles.utc-v1] +description = "UTC" +duckdb_time_zone = "UTC" + +[session_profiles.utc-v1.snowflake] +TIMEZONE = "UTC" +WEEK_START = 0 + +[edge_cases.numeric] +description = "numbers" +required = ["null", "zero"] + +[divergences.hash] +constructs = ["fn:HASH"] +default = "warn" +strict = "refuse" +reason = "values differ, consistently" +"#; + +pub(crate) fn observed_error(replace: &str, with: &str, fragment: Option<&str>) -> Option { + let message = parse_policy(&VALID_POLICY.replacen(replace, with, 1)) + .err() + .map(|error| error.to_string())?; + Some( + fragment + .filter(|fragment| message.contains(fragment)) + .map_or(message.clone(), str::to_owned), + ) +} + +pub(crate) fn valid_policy() -> Policy { + parse_policy(VALID_POLICY).expect("the base test policy is valid") +} + +pub(crate) fn equality_reason(snowflake: &str, duckdb: &str) -> Option { + let policy = valid_policy(); + matching_type_equality(&policy.comparator.type_equality, snowflake, duckdb) + .map(|equality| equality.reason.clone()) +} diff --git a/crates/sqlquack-policy/src/policy/tests/test_parse_policy.rs b/crates/sqlquack-policy/src/policy/tests/test_parse_policy.rs new file mode 100644 index 000000000..7e0a3845a --- /dev/null +++ b/crates/sqlquack-policy/src/policy/tests/test_parse_policy.rs @@ -0,0 +1,83 @@ +use crate::policy::tests::helpers::observed_error; +use crate::policy::tests::test_types::PolicyTestCase; + +#[test] +fn given_policy_text_when_parsing_then_schema_and_rules_are_enforced() { + let test_cases = [ + PolicyTestCase { + description: "the base policy is valid", + replace: "", + with: "", + expected_error: None, + }, + PolicyTestCase { + description: "an unknown top-level key is rejected", + replace: "schema_version = 1", + with: "schema_version = 1\noverrides = true", + expected_error: Some("unknown field `overrides`"), + }, + PolicyTestCase { + description: "an unknown comparator key is rejected", + replace: "timestamp_tz = \"instant\"", + with: "timestamp_tz = \"instant\"\nfloat_tolerance = 0.5", + expected_error: Some("unknown field `float_tolerance`"), + }, + PolicyTestCase { + description: "an unknown enumeration value is rejected", + replace: "column_names = \"case_insensitive\"", + with: "column_names = \"loose\"", + expected_error: Some("unknown variant `loose`"), + }, + PolicyTestCase { + description: "another schema version is rejected", + replace: "schema_version = 1", + with: "schema_version = 2", + expected_error: Some( + "the policy file is invalid: schema_version is 2, but this build reads version 1", + ), + }, + PolicyTestCase { + description: "a DuckDB pattern may not use unbound parameters", + replace: "duckdb = \"VARCHAR\"", + with: "duckdb = \"VARCHAR(m)\"", + expected_error: Some( + "the policy file is invalid: type_equality `VARCHAR(n)` = `VARCHAR(m)` uses \ + parameters the Snowflake side does not bind: m", + ), + }, + PolicyTestCase { + description: "strict mode may not be more lenient than the default", + replace: "default = \"warn\"\nstrict = \"refuse\"", + with: "default = \"refuse\"\nstrict = \"warn\"", + expected_error: Some( + "the policy file is invalid: divergence `hash` is treated more leniently in strict \ + mode", + ), + }, + PolicyTestCase { + description: "a divergence needs construct keys", + replace: "constructs = [\"fn:HASH\"]", + with: "constructs = [\"HASH\"]", + expected_error: Some( + "the policy file is invalid: divergence `hash` has an invalid construct key `HASH`", + ), + }, + PolicyTestCase { + description: "an edge-case category must require something", + replace: "required = [\"null\", \"zero\"]", + with: "required = []", + expected_error: Some( + "the policy file is invalid: edge-case category `numeric` requires no input", + ), + }, + ]; + + for test_case in test_cases { + assert_eq!( + observed_error(test_case.replace, test_case.with, test_case.expected_error).as_deref(), + test_case.expected_error, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-policy/src/policy/tests/test_type_equality.rs b/crates/sqlquack-policy/src/policy/tests/test_type_equality.rs new file mode 100644 index 000000000..4100cd5d7 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/tests/test_type_equality.rs @@ -0,0 +1,41 @@ +use crate::policy::tests::helpers::equality_reason; +use crate::policy::tests::test_types::TypeEqualityTestCase; + +#[test] +fn given_result_types_when_matching_then_only_declared_equalities_admit_them() { + let test_cases = [ + TypeEqualityTestCase { + description: "the same precision and scale", + snowflake: "NUMBER(10,2)", + duckdb: "DECIMAL(10,2)", + expected_reason: Some("same values"), + }, + TypeEqualityTestCase { + description: "a different scale is not admitted", + snowflake: "NUMBER(10,2)", + duckdb: "DECIMAL(10,3)", + expected_reason: None, + }, + TypeEqualityTestCase { + description: "an undeclared pair is not admitted", + snowflake: "NUMBER(38,0)", + duckdb: "BIGINT", + expected_reason: None, + }, + TypeEqualityTestCase { + description: "a bound parameter on one side only", + snowflake: "VARCHAR(16777216)", + duckdb: "VARCHAR", + expected_reason: Some("length does not change values"), + }, + ]; + + for test_case in test_cases { + assert_eq!( + equality_reason(test_case.snowflake, test_case.duckdb).as_deref(), + test_case.expected_reason, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-policy/src/policy/tests/test_types.rs b/crates/sqlquack-policy/src/policy/tests/test_types.rs new file mode 100644 index 000000000..fb9012040 --- /dev/null +++ b/crates/sqlquack-policy/src/policy/tests/test_types.rs @@ -0,0 +1,13 @@ +pub(crate) struct PolicyTestCase { + pub(crate) description: &'static str, + pub(crate) replace: &'static str, + pub(crate) with: &'static str, + pub(crate) expected_error: Option<&'static str>, +} + +pub(crate) struct TypeEqualityTestCase { + pub(crate) description: &'static str, + pub(crate) snowflake: &'static str, + pub(crate) duckdb: &'static str, + pub(crate) expected_reason: Option<&'static str>, +} diff --git a/sqlquack.toml b/sqlquack.toml new file mode 100644 index 000000000..47042228e --- /dev/null +++ b/sqlquack.toml @@ -0,0 +1,122 @@ +# SQLQuack evidence policy. +# +# This is the only place that holds settings which could be weakened to make a failing case pass. +# No case, rule or command-line flag overrides it. Every change is reviewed on its own. +# The schema lives in crates/sqlquack-policy; unknown keys are rejected. + +schema_version = 1 + +[comparator] +# Snowflake upper-cases unquoted result column names; DuckDB keeps them as written. +column_names = "case_insensitive" +# DuckDB's default_null_order during replay: Snowflake's default ordering (NULLs sort high). +null_order = "nulls_last_on_asc_first_on_desc" +# Zoned timestamps must denote the same instant. +timestamp_tz = "instant" + +# Floats compare bitwise (every NaN is one value). +[comparator.float_ulps] +float32 = 0 +float64 = 0 + +[[comparator.type_equality]] +snowflake = "NUMBER(p,s)" +duckdb = "DECIMAL(p,s)" +reason = "Fixed-point numbers with the same precision and scale hold the same values." + +[[comparator.type_equality]] +snowflake = "FLOAT" +duckdb = "DOUBLE" +reason = "Snowflake FLOAT is a 64-bit IEEE 754 double." + +[[comparator.type_equality]] +snowflake = "VARCHAR(n)" +duckdb = "VARCHAR" +reason = "Text values compare bytewise; the declared Snowflake length does not change a value." + +[[comparator.type_equality]] +snowflake = "BOOLEAN" +duckdb = "BOOLEAN" +reason = "Same type." + +[[comparator.type_equality]] +snowflake = "DATE" +duckdb = "DATE" +reason = "Same type." + +[[comparator.type_equality]] +snowflake = "TIME(p)" +duckdb = "TIME" +reason = "Times compare at nanoseconds, so a precision mismatch shows up as a value difference." + +[[comparator.type_equality]] +snowflake = "TIMESTAMP_NTZ(p)" +duckdb = "TIMESTAMP" +reason = "Naive timestamps compare at nanoseconds, so a precision mismatch shows up as a value difference." + +[[comparator.type_equality]] +snowflake = "TIMESTAMP_TZ(p)" +duckdb = "TIMESTAMPTZ" +reason = "Zoned timestamps compare as instants (see timestamp_tz)." + +[[comparator.type_equality]] +snowflake = "TIMESTAMP_LTZ(p)" +duckdb = "TIMESTAMPTZ" +reason = "Local-zone timestamps compare as instants (see timestamp_tz)." + +[[comparator.type_equality]] +snowflake = "BINARY(n)" +duckdb = "BLOB" +reason = "Binary values compare bytewise; the declared Snowflake length does not change a value." + +[session_profiles.utc-v1] +description = "Snowflake defaults pinned explicitly, with the session in UTC." +duckdb_time_zone = "UTC" + +[session_profiles.utc-v1.snowflake] +TIMEZONE = "UTC" +TIMESTAMP_TYPE_MAPPING = "TIMESTAMP_NTZ" +WEEK_START = 0 +WEEK_OF_YEAR_POLICY = 0 +DEFAULT_NULL_ORDERING = "LAST" +QUOTED_IDENTIFIERS_IGNORE_CASE = false +USE_CACHED_RESULT = false + +[edge_cases.numeric] +description = "Arithmetic, rounding and numeric conversion." +required = ["null", "zero", "negative", "boundary"] + +[edge_cases.string] +description = "String functions and operators." +required = ["null", "empty", "whitespace", "unicode"] + +[edge_cases.ordering] +description = "ORDER BY, NULL placement and ties." +required = ["null", "ties"] + +# Known divergences: values differ from Snowflake but stay consistent within one run. Each still +# needs recorded cases proving that consistency before a rule may use it. + +[divergences.hash] +constructs = ["fn:HASH"] +default = "warn" +strict = "refuse" +reason = "Hash values differ from Snowflake; equal inputs still hash equally within one run." + +[divergences.random] +constructs = ["node:random"] +default = "warn" +strict = "refuse" +reason = "Random values differ from Snowflake by nature." + +[divergences.uuid] +constructs = ["fn:UUID_STRING"] +default = "warn" +strict = "refuse" +reason = "Generated identifiers differ from Snowflake; each stays unique within one run." + +[divergences.sequences] +constructs = ["fn:SEQ1", "fn:SEQ2", "fn:SEQ4", "fn:SEQ8"] +default = "warn" +strict = "refuse" +reason = "Sequence values and gaps differ from Snowflake; values stay unique within one run." From 1e673210d0cdcb0721d64d479869b4ee00c7bdec Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:06:49 +0100 Subject: [PATCH 02/11] feat: cases, pending files, DuckDB replay, verify and audit --- .gitignore | 4 + Cargo.lock | 2 + cases/numeric/addition.toml | 25 ++ cases/numeric/division.toml | 25 ++ cases/ordering/null_ordering.toml | 30 +++ cases/string/concat.toml | 31 +++ .../src/audit/main/compare_audit.rs | 101 ++++++++ .../sqlquack-evidence/src/audit/main/mod.rs | 2 + .../src/audit/main/sample_cases.rs | 35 +++ crates/sqlquack-evidence/src/audit/mod.rs | 4 + crates/sqlquack-evidence/src/audit/models.rs | 37 +++ .../src/cases/_helpers/files.rs | 38 +++ .../src/cases/_helpers/loading.rs | 162 ++++++++++++ .../src/cases/_helpers/mod.rs | 3 + .../src/cases/_helpers/source_sql.rs | 36 +++ .../src/cases/main/case_hash.rs | 14 + .../src/cases/main/load_cases.rs | 27 ++ .../sqlquack-evidence/src/cases/main/mod.rs | 2 + crates/sqlquack-evidence/src/cases/mod.rs | 8 + crates/sqlquack-evidence/src/cases/models.rs | 102 ++++++++ crates/sqlquack-evidence/src/cases/tests.rs | 8 + .../src/cases/tests/helpers.rs | 114 ++++++++ .../src/cases/tests/test_case_hash.rs | 48 ++++ .../src/cases/tests/test_load_cases.rs | 103 ++++++++ .../src/cases/tests/test_types.rs | 19 ++ crates/sqlquack-evidence/src/constants.rs | 31 +++ crates/sqlquack-evidence/src/errors.rs | 31 +++ crates/sqlquack-evidence/src/lib.rs | 13 +- .../sqlquack-evidence/src/pending/main/mod.rs | 1 + .../src/pending/main/write_pending.rs | 66 +++++ crates/sqlquack-evidence/src/pending/mod.rs | 4 + .../sqlquack-evidence/src/pending/models.rs | 37 +++ .../src/replay/main/duckdb_version.rs | 14 + .../sqlquack-evidence/src/replay/main/mod.rs | 2 + .../src/replay/main/replay_on_duckdb.rs | 25 ++ crates/sqlquack-evidence/src/replay/mod.rs | 4 + crates/sqlquack-evidence/src/replay/models.rs | 10 + .../src/results/_helpers/mod.rs | 1 + .../src/results/_helpers/reading.rs | 80 ++++++ .../src/results/main/load_result.rs | 50 ++++ .../sqlquack-evidence/src/results/main/mod.rs | 2 + .../src/results/main/snowflake_type.rs | 20 ++ crates/sqlquack-evidence/src/results/mod.rs | 5 + .../sqlquack-evidence/src/results/models.rs | 92 +++++++ .../src/verify/_helpers/judge.rs | 80 ++++++ .../src/verify/_helpers/listing.rs | 25 ++ .../src/verify/_helpers/mod.rs | 2 + .../sqlquack-evidence/src/verify/main/mod.rs | 1 + .../src/verify/main/verify_cases.rs | 38 +++ crates/sqlquack-evidence/src/verify/mod.rs | 9 + crates/sqlquack-evidence/src/verify/models.rs | 53 ++++ crates/sqlquack-evidence/src/verify/tests.rs | 6 + .../src/verify/tests/helpers.rs | 244 ++++++++++++++++++ .../src/verify/tests/test_round_trip.rs | 48 ++++ .../src/verify/tests/test_types.rs | 31 +++ crates/sqlquack-evidence/src/verify/types.rs | 10 + crates/sqlquack/Cargo.toml | 2 + crates/sqlquack/src/analysis/models.rs | 34 +++ .../src/cli/_helpers/audit_command.rs | 133 ++++++++++ crates/sqlquack/src/cli/_helpers/evidence.rs | 31 +++ crates/sqlquack/src/cli/_helpers/mod.rs | 4 + .../src/cli/_helpers/pending_command.rs | 41 +++ .../src/cli/_helpers/verify_command.rs | 98 +++++++ crates/sqlquack/src/cli/main/run_cli.rs | 6 + crates/sqlquack/src/cli/models.rs | 73 ++++++ crates/sqlquack/tests/evidence.rs | 6 + crates/sqlquack/tests/evidence/helpers.rs | 43 +++ .../tests/evidence/test_evidence_commands.rs | 46 ++++ crates/sqlquack/tests/evidence/test_types.rs | 13 + evidence/results/README.md | 7 + 70 files changed, 2551 insertions(+), 1 deletion(-) create mode 100644 cases/numeric/addition.toml create mode 100644 cases/numeric/division.toml create mode 100644 cases/ordering/null_ordering.toml create mode 100644 cases/string/concat.toml create mode 100644 crates/sqlquack-evidence/src/audit/main/compare_audit.rs create mode 100644 crates/sqlquack-evidence/src/audit/main/mod.rs create mode 100644 crates/sqlquack-evidence/src/audit/main/sample_cases.rs create mode 100644 crates/sqlquack-evidence/src/audit/mod.rs create mode 100644 crates/sqlquack-evidence/src/audit/models.rs create mode 100644 crates/sqlquack-evidence/src/cases/_helpers/files.rs create mode 100644 crates/sqlquack-evidence/src/cases/_helpers/loading.rs create mode 100644 crates/sqlquack-evidence/src/cases/_helpers/mod.rs create mode 100644 crates/sqlquack-evidence/src/cases/_helpers/source_sql.rs create mode 100644 crates/sqlquack-evidence/src/cases/main/case_hash.rs create mode 100644 crates/sqlquack-evidence/src/cases/main/load_cases.rs create mode 100644 crates/sqlquack-evidence/src/cases/main/mod.rs create mode 100644 crates/sqlquack-evidence/src/cases/mod.rs create mode 100644 crates/sqlquack-evidence/src/cases/models.rs create mode 100644 crates/sqlquack-evidence/src/cases/tests.rs create mode 100644 crates/sqlquack-evidence/src/cases/tests/helpers.rs create mode 100644 crates/sqlquack-evidence/src/cases/tests/test_case_hash.rs create mode 100644 crates/sqlquack-evidence/src/cases/tests/test_load_cases.rs create mode 100644 crates/sqlquack-evidence/src/cases/tests/test_types.rs create mode 100644 crates/sqlquack-evidence/src/constants.rs create mode 100644 crates/sqlquack-evidence/src/errors.rs create mode 100644 crates/sqlquack-evidence/src/pending/main/mod.rs create mode 100644 crates/sqlquack-evidence/src/pending/main/write_pending.rs create mode 100644 crates/sqlquack-evidence/src/pending/mod.rs create mode 100644 crates/sqlquack-evidence/src/pending/models.rs create mode 100644 crates/sqlquack-evidence/src/replay/main/duckdb_version.rs create mode 100644 crates/sqlquack-evidence/src/replay/main/mod.rs create mode 100644 crates/sqlquack-evidence/src/replay/main/replay_on_duckdb.rs create mode 100644 crates/sqlquack-evidence/src/replay/mod.rs create mode 100644 crates/sqlquack-evidence/src/replay/models.rs create mode 100644 crates/sqlquack-evidence/src/results/_helpers/mod.rs create mode 100644 crates/sqlquack-evidence/src/results/_helpers/reading.rs create mode 100644 crates/sqlquack-evidence/src/results/main/load_result.rs create mode 100644 crates/sqlquack-evidence/src/results/main/mod.rs create mode 100644 crates/sqlquack-evidence/src/results/main/snowflake_type.rs create mode 100644 crates/sqlquack-evidence/src/results/mod.rs create mode 100644 crates/sqlquack-evidence/src/results/models.rs create mode 100644 crates/sqlquack-evidence/src/verify/_helpers/judge.rs create mode 100644 crates/sqlquack-evidence/src/verify/_helpers/listing.rs create mode 100644 crates/sqlquack-evidence/src/verify/_helpers/mod.rs create mode 100644 crates/sqlquack-evidence/src/verify/main/mod.rs create mode 100644 crates/sqlquack-evidence/src/verify/main/verify_cases.rs create mode 100644 crates/sqlquack-evidence/src/verify/mod.rs create mode 100644 crates/sqlquack-evidence/src/verify/models.rs create mode 100644 crates/sqlquack-evidence/src/verify/tests.rs create mode 100644 crates/sqlquack-evidence/src/verify/tests/helpers.rs create mode 100644 crates/sqlquack-evidence/src/verify/tests/test_round_trip.rs create mode 100644 crates/sqlquack-evidence/src/verify/tests/test_types.rs create mode 100644 crates/sqlquack-evidence/src/verify/types.rs create mode 100644 crates/sqlquack/src/cli/_helpers/audit_command.rs create mode 100644 crates/sqlquack/src/cli/_helpers/evidence.rs create mode 100644 crates/sqlquack/src/cli/_helpers/pending_command.rs create mode 100644 crates/sqlquack/src/cli/_helpers/verify_command.rs create mode 100644 crates/sqlquack/tests/evidence.rs create mode 100644 crates/sqlquack/tests/evidence/helpers.rs create mode 100644 crates/sqlquack/tests/evidence/test_evidence_commands.rs create mode 100644 crates/sqlquack/tests/evidence/test_types.rs create mode 100644 evidence/results/README.md diff --git a/.gitignore b/.gitignore index 181c3b5e6..518d4cadd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ __pycache__/ /.agents/skills/ /.claude/skills/ /.opencode/skills/ + +# Derived evidence work files +/evidence/pending/ +/evidence/audit/ diff --git a/Cargo.lock b/Cargo.lock index 017b0da98..001186cd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1287,6 +1287,8 @@ dependencies = [ "sqlquack-compare", "sqlquack-core", "sqlquack-duckdb", + "sqlquack-evidence", + "sqlquack-policy", "sqlquack-rules", "sqlquack-snowflake", "tempfile", diff --git a/cases/numeric/addition.toml b/cases/numeric/addition.toml new file mode 100644 index 000000000..f073a4fa9 --- /dev/null +++ b/cases/numeric/addition.toml @@ -0,0 +1,25 @@ +category = "numeric" +construct = "node:add" +session_profile = "utc-v1" + +[[case]] +id = "integers" +description = "Integer addition, including NULL and the largest 38-digit result." +columns = [{ name = "a", type = "NUMBER(38,0)" }, { name = "b", type = "NUMBER(38,0)" }] +rows = [ + ["1", "2"], + ["-5", "3"], + ["0", "0"], + ["NULL", "1"], + ["99999999999999999999999999999999999998", "1"], +] +expression = "a + b" +tags = ["null", "zero", "negative", "boundary"] + +[[case]] +id = "mixed_scales" +description = "Adding numbers of different scales; the result scale is the larger one." +columns = [{ name = "a", type = "NUMBER(10,2)" }, { name = "b", type = "NUMBER(12,4)" }] +rows = [["1.25", "0.0001"], ["-0.01", "0"], ["NULL", "NULL"]] +expression = "a + b" +tags = ["null", "zero", "negative"] diff --git a/cases/numeric/division.toml b/cases/numeric/division.toml new file mode 100644 index 000000000..c31b7aa67 --- /dev/null +++ b/cases/numeric/division.toml @@ -0,0 +1,25 @@ +category = "numeric" +construct = "node:div" +session_profile = "utc-v1" + +[[case]] +id = "decimal_division" +description = "Decimal division, whose result scale Snowflake raises." +columns = [{ name = "a", type = "NUMBER(10,2)" }, { name = "b", type = "NUMBER(10,2)" }] +rows = [ + ["10.00", "3.00"], + ["-1.00", "8.00"], + ["0", "7"], + ["NULL", "2"], + ["99999999.99", "0.01"], +] +expression = "a / b" +tags = ["null", "zero", "negative", "boundary"] + +[[case]] +id = "division_by_zero" +description = "Division by zero is an error, not NULL." +columns = [{ name = "a", type = "NUMBER(38,0)" }, { name = "b", type = "NUMBER(38,0)" }] +rows = [["1", "0"]] +expression = "a / b" +tags = ["zero"] diff --git a/cases/ordering/null_ordering.toml b/cases/ordering/null_ordering.toml new file mode 100644 index 000000000..1effad266 --- /dev/null +++ b/cases/ordering/null_ordering.toml @@ -0,0 +1,30 @@ +category = "ordering" +construct = "clause:order_by" +session_profile = "utc-v1" + +[[case]] +id = "ascending_default" +description = "ORDER BY without NULLS FIRST or LAST, ascending." +columns = [{ name = "quantity", type = "NUMBER(38,0)" }] +rows = [["3"], ["NULL"], ["1"], ["3"]] +query = "SELECT quantity FROM input ORDER BY quantity" +order = "ordered" +tags = ["null", "ties"] + +[[case]] +id = "descending_default" +description = "ORDER BY without NULLS FIRST or LAST, descending." +columns = [{ name = "quantity", type = "NUMBER(38,0)" }] +rows = [["3"], ["NULL"], ["1"], ["3"]] +query = "SELECT quantity FROM input ORDER BY quantity DESC" +order = "ordered" +tags = ["null", "ties"] + +[[case]] +id = "ascending_nulls_first" +description = "An explicit NULLS FIRST, ascending." +columns = [{ name = "quantity", type = "NUMBER(38,0)" }] +rows = [["3"], ["NULL"], ["1"], ["3"]] +query = "SELECT quantity FROM input ORDER BY quantity ASC NULLS FIRST" +order = "ordered" +tags = ["null", "ties"] diff --git a/cases/string/concat.toml b/cases/string/concat.toml new file mode 100644 index 000000000..972a6bd14 --- /dev/null +++ b/cases/string/concat.toml @@ -0,0 +1,31 @@ +category = "string" +construct = "fn:CONCAT" +session_profile = "utc-v1" + +[[case]] +id = "concat_function" +description = "CONCAT with NULL, empty, whitespace and non-ASCII arguments." +columns = [{ name = "a", type = "VARCHAR" }, { name = "b", type = "VARCHAR" }] +rows = [ + ["'gift'", "'wrap'"], + ["'gift'", "NULL"], + ["NULL", "NULL"], + ["''", "'wrap'"], + ["' '", "'café'"], +] +expression = "CONCAT(a, b)" +tags = ["null", "empty", "whitespace", "unicode"] + +[[case]] +id = "concat_operator" +description = "The || operator on the same inputs." +columns = [{ name = "a", type = "VARCHAR" }, { name = "b", type = "VARCHAR" }] +rows = [ + ["'gift'", "'wrap'"], + ["'gift'", "NULL"], + ["NULL", "NULL"], + ["''", "'wrap'"], + ["' '", "'café'"], +] +expression = "a || b" +tags = ["null", "empty", "whitespace", "unicode"] diff --git a/crates/sqlquack-evidence/src/audit/main/compare_audit.rs b/crates/sqlquack-evidence/src/audit/main/compare_audit.rs new file mode 100644 index 000000000..6d9d27600 --- /dev/null +++ b/crates/sqlquack-evidence/src/audit/main/compare_audit.rs @@ -0,0 +1,101 @@ +//! `compare_audit`: fresh recordings against the stored results of the same cases. + +use std::path::Path; + +use sqlquack_compare::comparison::main::compare_recordings::compare_recordings; +use sqlquack_compare::comparison::models::RowOrder; + +use crate::audit::models::{AuditEntry, AuditOutcome}; +use crate::cases::models::LoadedCase; +use crate::errors::EvidenceError; +use crate::results::main::load_result::load_result; +use crate::results::models::StoredResult; +use crate::verify::_helpers::listing::recorded_hashes; + +/// One entry per fresh result in `fresh`, compared with the stored result in `stored`. +pub fn compare_audit( + cases: &[LoadedCase], + stored: &Path, + fresh: &Path, +) -> Result, EvidenceError> { + Ok(recorded_hashes(fresh)? + .into_iter() + .map(|hash| { + let case = cases.iter().find(|case| case.hash == hash); + let order = case.map_or(RowOrder::Ordered, |case| case.order); + let stored_result = load_result(stored, &hash); + let fresh_result = load_result(fresh, &hash); + AuditEntry { + reference: case.map(|case| case.reference.clone()), + stored_warehouse_version: warehouse_version(&stored_result), + fresh_warehouse_version: warehouse_version(&fresh_result), + outcome: outcome(stored_result, fresh_result, order), + hash, + } + }) + .collect()) +} + +fn warehouse_version(result: &Result, EvidenceError>) -> Option { + result + .as_ref() + .ok() + .and_then(Option::as_ref) + .map(|stored| stored.metadata.warehouse_version.clone()) +} + +fn outcome( + stored: Result, EvidenceError>, + fresh: Result, EvidenceError>, + order: RowOrder, +) -> AuditOutcome { + let (stored, fresh) = match (stored, fresh) { + (Ok(Some(stored)), Ok(Some(fresh))) => (stored, fresh), + (Ok(None), _) => { + return AuditOutcome::Invalid { + detail: "no stored result for this hash".to_owned(), + }; + } + (Err(error), _) | (_, Err(error)) => { + return AuditOutcome::Invalid { + detail: error.to_string(), + }; + } + (_, Ok(None)) => { + return AuditOutcome::Invalid { + detail: "the fresh result disappeared".to_owned(), + }; + } + }; + match (&stored.rows, &fresh.rows) { + (Some(stored_rows), Some(fresh_rows)) => { + match compare_recordings(stored_rows, fresh_rows, order) { + Ok(()) => AuditOutcome::Matched, + Err(mismatch) => AuditOutcome::Differed { + detail: mismatch.to_string(), + }, + } + } + (None, None) if same_error(&stored, &fresh) => AuditOutcome::Matched, + _ => AuditOutcome::Differed { + detail: format!( + "stored outcome {:?} ({:?}), fresh outcome {:?} ({:?})", + stored.metadata.outcome, + stored.metadata.error.as_ref().map(|error| &error.sqlstate), + fresh.metadata.outcome, + fresh.metadata.error.as_ref().map(|error| &error.sqlstate) + ), + }, + } +} + +fn same_error(stored: &StoredResult, fresh: &StoredResult) -> bool { + let key = |result: &StoredResult| { + result + .metadata + .error + .as_ref() + .map(|error| (error.errno, error.sqlstate.clone())) + }; + key(stored) == key(fresh) +} diff --git a/crates/sqlquack-evidence/src/audit/main/mod.rs b/crates/sqlquack-evidence/src/audit/main/mod.rs new file mode 100644 index 000000000..fdd9d5ad9 --- /dev/null +++ b/crates/sqlquack-evidence/src/audit/main/mod.rs @@ -0,0 +1,2 @@ +pub mod compare_audit; +pub mod sample_cases; diff --git a/crates/sqlquack-evidence/src/audit/main/sample_cases.rs b/crates/sqlquack-evidence/src/audit/main/sample_cases.rs new file mode 100644 index 000000000..0af574c9b --- /dev/null +++ b/crates/sqlquack-evidence/src/audit/main/sample_cases.rs @@ -0,0 +1,35 @@ +//! `sample_cases`: a reproducible sample of recorded cases. + +use std::path::Path; + +use sha2::{Digest, Sha256}; + +use crate::cases::models::LoadedCase; + +/// About `percent`% of the recorded cases, chosen by hashing each case hash with `seed`. +pub fn sample_cases<'a>( + cases: &'a [LoadedCase], + results: &Path, + percent: f64, + seed: u64, +) -> Vec<&'a LoadedCase> { + let mut scored: Vec<(f64, &LoadedCase)> = cases + .iter() + .filter(|case| results.join(format!("{}.json", case.hash)).exists()) + .map(|case| (score(&case.hash, seed), case)) + .collect(); + scored.sort_by(|left, right| left.0.total_cmp(&right.0)); + let wanted = ((scored.len() as f64) * percent.clamp(0.0, 100.0) / 100.0).ceil() as usize; + scored + .into_iter() + .take(wanted) + .map(|(_, case)| case) + .collect() +} + +fn score(hash: &str, seed: u64) -> f64 { + let digest = Sha256::digest(format!("{seed}:{hash}").as_bytes()); + let mut prefix = [0u8; 8]; + prefix.copy_from_slice(&digest[..8]); + u64::from_be_bytes(prefix) as f64 / u64::MAX as f64 +} diff --git a/crates/sqlquack-evidence/src/audit/mod.rs b/crates/sqlquack-evidence/src/audit/mod.rs new file mode 100644 index 000000000..9a9ffbcb5 --- /dev/null +++ b/crates/sqlquack-evidence/src/audit/mod.rs @@ -0,0 +1,4 @@ +//! Audits: re-record a sample of cases and compare with the stored results. + +pub mod main; +pub mod models; diff --git a/crates/sqlquack-evidence/src/audit/models.rs b/crates/sqlquack-evidence/src/audit/models.rs new file mode 100644 index 000000000..01942aa7a --- /dev/null +++ b/crates/sqlquack-evidence/src/audit/models.rs @@ -0,0 +1,37 @@ +//! Audit outcomes. + +use serde::Serialize; + +/// One re-recorded case. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AuditEntry { + /// The case hash. + pub hash: String, + /// `#`, when a current case names the hash. + pub reference: Option, + /// The warehouse version of the stored result. + pub stored_warehouse_version: Option, + /// The warehouse version of the fresh result. + pub fresh_warehouse_version: Option, + /// The comparison. + #[serde(flatten)] + pub outcome: AuditOutcome, +} + +/// How a fresh recording compares with the stored one. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum AuditOutcome { + /// Identical results. + Matched, + /// Different results; with equal warehouse versions this suggests the stored result was altered. + Differed { + /// The first difference. + detail: String, + }, + /// A result could not be read. + Invalid { + /// Why. + detail: String, + }, +} diff --git a/crates/sqlquack-evidence/src/cases/_helpers/files.rs b/crates/sqlquack-evidence/src/cases/_helpers/files.rs new file mode 100644 index 000000000..2efdf06f2 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/_helpers/files.rs @@ -0,0 +1,38 @@ +//! Reading case files. + +use std::path::Path; + +use walkdir::WalkDir; + +use crate::cases::models::CaseFile; +use crate::errors::EvidenceError; + +pub(crate) fn read_case_files(directory: &Path) -> Result, EvidenceError> { + let mut files: Vec<(String, CaseFile)> = Vec::new(); + let mut problems: Vec = Vec::new(); + for entry in WalkDir::new(directory).sort_by_file_name() { + let entry = entry.map_err(|error| EvidenceError::Io(error.to_string()))?; + let path = entry.path(); + if !entry.file_type().is_file() + || path.extension().is_none_or(|extension| extension != "toml") + { + continue; + } + let relative = path + .strip_prefix(directory) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let text = std::fs::read_to_string(path) + .map_err(|error| EvidenceError::Io(format!("{}: {error}", path.display())))?; + match toml::from_str::(&text) { + Ok(file) => files.push((relative, file)), + Err(error) => problems.push(format!("{relative}: {error}")), + } + } + if problems.is_empty() { + Ok(files) + } else { + Err(EvidenceError::InvalidCases(problems)) + } +} diff --git a/crates/sqlquack-evidence/src/cases/_helpers/loading.rs b/crates/sqlquack-evidence/src/cases/_helpers/loading.rs new file mode 100644 index 000000000..caf100e1f --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/_helpers/loading.rs @@ -0,0 +1,162 @@ +//! Validating one case file and expanding its cases. + +use std::collections::BTreeSet; + +use sqlquack_compare::comparison::models::RowOrder; +use sqlquack_policy::policy::models::Policy; + +use crate::cases::_helpers::source_sql::source_sql; +use crate::cases::main::case_hash::case_hash; +use crate::cases::models::{CaseDefinition, CaseFile, HashedContent, LoadedCase, QueryOrder}; +use crate::constants::{EVIDENCE_FORMAT, ORDINAL_COLUMN, SOURCE_DIALECT, VALUE_COLUMN}; + +pub(crate) fn load_file( + relative: &str, + file: &CaseFile, + policy: &Policy, +) -> Result, Vec> { + let mut problems = file_problems(relative, file, policy); + problems.extend( + file.cases + .iter() + .flat_map(|case| case_problems(relative, case)), + ); + let Some(profile) = policy.session_profiles.get(&file.session_profile) else { + return Err(problems); + }; + if !problems.is_empty() { + return Err(problems); + } + Ok(file + .cases + .iter() + .map(|case| { + let sql = source_sql(case); + let hash = case_hash(&HashedContent { + format: EVIDENCE_FORMAT, + source_dialect: SOURCE_DIALECT, + session_parameters: &profile.snowflake, + source_sql: &sql, + }); + LoadedCase { + reference: format!("{relative}#{}", case.id), + category: file.category.clone(), + construct: file.construct.clone(), + session_profile: file.session_profile.clone(), + session_parameters: profile.snowflake.clone(), + source_sql: sql, + order: row_order(case), + hash, + } + }) + .collect()) +} + +fn row_order(case: &CaseDefinition) -> RowOrder { + match (&case.expression, case.order) { + (None, Some(QueryOrder::Bag)) => RowOrder::Bag, + _ => RowOrder::Ordered, + } +} + +fn file_problems(relative: &str, file: &CaseFile, policy: &Policy) -> Vec { + let mut problems: Vec = Vec::new(); + if !policy.session_profiles.contains_key(&file.session_profile) { + problems.push(format!( + "{relative}: unknown session profile `{}`", + file.session_profile + )); + } + if file.cases.is_empty() { + problems.push(format!("{relative}: declares no case")); + } + let ids: BTreeSet<&str> = file.cases.iter().map(|case| case.id.as_str()).collect(); + if ids.len() != file.cases.len() { + problems.push(format!("{relative}: repeats a case id")); + } + match policy.edge_cases.get(&file.category) { + None => problems.push(format!("{relative}: unknown category `{}`", file.category)), + Some(requirement) => { + let covered: BTreeSet<&str> = file + .cases + .iter() + .flat_map(|case| case.tags.iter().map(String::as_str)) + .collect(); + let missing: Vec<&str> = requirement + .required + .iter() + .map(String::as_str) + .filter(|tag| !covered.contains(tag)) + .collect(); + if !missing.is_empty() { + problems.push(format!( + "{relative}: category `{}` requires inputs no case covers: {}", + file.category, + missing.join(", ") + )); + } + } + } + problems +} + +fn case_problems(relative: &str, case: &CaseDefinition) -> Vec { + let label = format!("{relative}#{}", case.id); + let mut problems: Vec = Vec::new(); + if !is_identifier(&case.id) { + problems.push(format!("{label}: the id must be a lower-case identifier")); + } + if case.description.trim().is_empty() { + problems.push(format!("{label}: has no description")); + } + if case.rows.is_empty() { + problems.push(format!("{label}: needs at least one input row")); + } + if case.rows.iter().any(|row| row.len() != case.columns.len()) { + problems.push(format!("{label}: every row needs one value per column")); + } + let names: Vec<&str> = case + .columns + .iter() + .map(|column| column.name.as_str()) + .collect(); + if names + .iter() + .any(|name| !is_identifier(name) || [ORDINAL_COLUMN, VALUE_COLUMN].contains(name)) + { + problems.push(format!( + "{label}: column names must be lower-case identifiers other than `{ORDINAL_COLUMN}` and `{VALUE_COLUMN}`" + )); + } + match (&case.expression, &case.query, case.order) { + (Some(_), None, None) => {} + (None, Some(query), Some(_)) => { + if !query + .trim_start() + .to_ascii_uppercase() + .starts_with("SELECT") + { + problems.push(format!("{label}: a query must start with SELECT")); + } + } + (Some(_), None, Some(_)) => problems.push(format!( + "{label}: expressions are always in input order; drop `order`" + )), + (None, Some(_), None) => problems.push(format!( + "{label}: a query needs `order = \"ordered\"` or `\"bag\"`" + )), + _ => problems.push(format!( + "{label}: needs exactly one of `expression` and `query`" + )), + } + problems +} + +fn is_identifier(name: &str) -> bool { + name.chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && name.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' + }) +} diff --git a/crates/sqlquack-evidence/src/cases/_helpers/mod.rs b/crates/sqlquack-evidence/src/cases/_helpers/mod.rs new file mode 100644 index 000000000..e9515f227 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/_helpers/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod files; +pub(crate) mod loading; +pub(crate) mod source_sql; diff --git a/crates/sqlquack-evidence/src/cases/_helpers/source_sql.rs b/crates/sqlquack-evidence/src/cases/_helpers/source_sql.rs new file mode 100644 index 000000000..e431f72b8 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/_helpers/source_sql.rs @@ -0,0 +1,36 @@ +//! The source SQL a case expands to: typed input rows, then the expression or query. + +use crate::cases::models::CaseDefinition; +use crate::constants::{INPUT_RELATION, ORDINAL_COLUMN, VALUE_COLUMN}; + +pub(crate) fn source_sql(case: &CaseDefinition) -> String { + let rows: Vec = case + .rows + .iter() + .enumerate() + .map(|(index, row)| input_row(case, index + 1, row)) + .collect(); + let input = format!( + "WITH {INPUT_RELATION} AS (\n {}\n)\n", + rows.join("\n UNION ALL\n ") + ); + match (&case.expression, &case.query) { + (Some(expression), _) => format!( + "{input}SELECT {ORDINAL_COLUMN}, {expression} AS {VALUE_COLUMN}\nFROM {INPUT_RELATION}\nORDER BY {ORDINAL_COLUMN}" + ), + (None, Some(query)) => format!("{input}{}", query.trim()), + (None, None) => input, + } +} + +fn input_row(case: &CaseDefinition, ordinal: usize, row: &[String]) -> String { + let cells: Vec = case + .columns + .iter() + .zip(row) + .map(|(column, literal)| { + format!("CAST({literal} AS {}) AS {}", column.data_type, column.name) + }) + .collect(); + format!("SELECT {ordinal} AS {ORDINAL_COLUMN}, {}", cells.join(", ")) +} diff --git a/crates/sqlquack-evidence/src/cases/main/case_hash.rs b/crates/sqlquack-evidence/src/cases/main/case_hash.rs new file mode 100644 index 000000000..706837f95 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/main/case_hash.rs @@ -0,0 +1,14 @@ +//! `case_hash`: the content hash that names a case's result. + +use sha2::{Digest, Sha256}; + +use crate::cases::models::HashedContent; + +/// SHA-256 of the canonical JSON of the hashed content, as lower-case hex. +pub fn case_hash(content: &HashedContent<'_>) -> String { + let canonical = serde_json::to_vec(content).unwrap_or_default(); + Sha256::digest(&canonical) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/crates/sqlquack-evidence/src/cases/main/load_cases.rs b/crates/sqlquack-evidence/src/cases/main/load_cases.rs new file mode 100644 index 000000000..f19d73544 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/main/load_cases.rs @@ -0,0 +1,27 @@ +//! `load_cases`: every case under the cases directory, validated against the policy. + +use std::path::Path; + +use sqlquack_policy::policy::models::Policy; + +use crate::cases::_helpers::files::read_case_files; +use crate::cases::_helpers::loading::load_file; +use crate::cases::models::LoadedCase; +use crate::errors::EvidenceError; + +/// Loads and validates every `.toml` case file under `directory`, sorted by reference. +pub fn load_cases(directory: &Path, policy: &Policy) -> Result, EvidenceError> { + let mut cases: Vec = Vec::new(); + let mut problems: Vec = Vec::new(); + for (relative, file) in read_case_files(directory)? { + match load_file(&relative, &file, policy) { + Ok(loaded) => cases.extend(loaded), + Err(found) => problems.extend(found), + } + } + if !problems.is_empty() { + return Err(EvidenceError::InvalidCases(problems)); + } + cases.sort_by(|left, right| left.reference.cmp(&right.reference)); + Ok(cases) +} diff --git a/crates/sqlquack-evidence/src/cases/main/mod.rs b/crates/sqlquack-evidence/src/cases/main/mod.rs new file mode 100644 index 000000000..63e0ec987 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/main/mod.rs @@ -0,0 +1,2 @@ +pub mod case_hash; +pub mod load_cases; diff --git a/crates/sqlquack-evidence/src/cases/mod.rs b/crates/sqlquack-evidence/src/cases/mod.rs new file mode 100644 index 000000000..fe44eb6a3 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/mod.rs @@ -0,0 +1,8 @@ +//! Authored case files, the source SQL they expand to and their content hashes. + +pub(crate) mod _helpers; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlquack-evidence/src/cases/models.rs b/crates/sqlquack-evidence/src/cases/models.rs new file mode 100644 index 000000000..db47622b5 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/models.rs @@ -0,0 +1,102 @@ +//! The case file format and loaded cases. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use sqlquack_compare::comparison::models::RowOrder; +use sqlquack_policy::policy::models::SessionValue; + +/// One authored case file: shared settings and its cases. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CaseFile { + /// The edge-case category whose required inputs the file's cases must cover. + pub category: String, + /// The construct the cases exercise, such as `fn:CONCAT`. + pub construct: String, + /// The session profile the cases are recorded and replayed under. + pub session_profile: String, + /// The cases. + #[serde(rename = "case")] + pub cases: Vec, +} + +/// One case: typed input rows and an expression or query over them. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CaseDefinition { + /// Unique within the file. + pub id: String, + /// What the case checks. + pub description: String, + /// Input columns and their source-dialect types. + pub columns: Vec, + /// Input rows; each cell is a source-dialect literal such as `'a'`, `1.5` or `NULL`. + pub rows: Vec>, + /// An expression over the input columns, evaluated per row in input order. + #[serde(default)] + pub expression: Option, + /// A query over the `input` relation. + #[serde(default)] + pub query: Option, + /// For queries: `ordered` when the query fixes the row order, else `bag`. + #[serde(default)] + pub order: Option, + /// The edge-case inputs the case covers. + pub tags: Vec, +} + +/// An input column. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputColumn { + /// The column name, a lower-case identifier. + pub name: String, + /// The source-dialect type. + #[serde(rename = "type")] + pub data_type: String, +} + +/// How a query's rows are ordered. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QueryOrder { + /// The query's ORDER BY fixes the order. + Ordered, + /// Rows compare as a multiset. + Bag, +} + +/// A case ready to record or verify. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoadedCase { + /// `#`, relative to the cases directory. + pub reference: String, + /// The category of its file. + pub category: String, + /// The construct of its file. + pub construct: String, + /// The session profile name. + pub session_profile: String, + /// The Snowflake session parameters it records under. + pub session_parameters: BTreeMap, + /// The full source SQL that is recorded. + pub source_sql: String, + /// How rows compare. + pub order: RowOrder, + /// The content hash naming its result. + pub hash: String, +} + +/// The hashed content of a case: everything that can change the recorded result. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct HashedContent<'a> { + /// The evidence format. + pub format: u32, + /// The source dialect. + pub source_dialect: &'a str, + /// The session parameters. + pub session_parameters: &'a BTreeMap, + /// The source SQL. + pub source_sql: &'a str, +} diff --git a/crates/sqlquack-evidence/src/cases/tests.rs b/crates/sqlquack-evidence/src/cases/tests.rs new file mode 100644 index 000000000..026220172 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/tests.rs @@ -0,0 +1,8 @@ +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_case_hash.rs"] +mod test_case_hash; +#[path = "tests/test_load_cases.rs"] +mod test_load_cases; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlquack-evidence/src/cases/tests/helpers.rs b/crates/sqlquack-evidence/src/cases/tests/helpers.rs new file mode 100644 index 000000000..0fc91a47c --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/tests/helpers.rs @@ -0,0 +1,114 @@ +use std::path::Path; + +use sqlquack_policy::policy::main::parse_policy::parse_policy; +use sqlquack_policy::policy::models::Policy; + +use crate::cases::main::load_cases::load_cases; +use crate::cases::models::LoadedCase; + +pub(crate) const POLICY: &str = r#" +schema_version = 1 + +[comparator] +column_names = "case_insensitive" +null_order = "nulls_last_on_asc_first_on_desc" +timestamp_tz = "instant" +type_equality = [] + +[comparator.float_ulps] +float32 = 0 +float64 = 0 + +[session_profiles.utc-v1] +description = "UTC" +duckdb_time_zone = "UTC" + +[session_profiles.utc-v1.snowflake] +TIMEZONE = "UTC" + +[session_profiles.utc-v2] +description = "UTC, weeks from Monday" +duckdb_time_zone = "UTC" + +[session_profiles.utc-v2.snowflake] +TIMEZONE = "UTC" +WEEK_START = 1 + +[edge_cases.string] +description = "strings" +required = ["null", "empty"] +"#; + +pub(crate) const CASE_FILE: &str = r#" +category = "string" +construct = "fn:CONCAT" +session_profile = "utc-v1" + +[[case]] +id = "concat" +description = "CONCAT with NULL and empty inputs." +columns = [{ name = "a", type = "VARCHAR" }, { name = "b", type = "VARCHAR" }] +rows = [["'gift'", "NULL"], ["''", "'wrap'"]] +expression = "CONCAT(a, b)" +tags = ["null", "empty"] + +[[case]] +id = "sorted" +description = "Sorted values." +columns = [{ name = "a", type = "VARCHAR" }] +rows = [["'b'"], ["'a'"]] +query = "SELECT a FROM input ORDER BY a" +order = "ordered" +tags = [] +"#; + +fn policy() -> Policy { + parse_policy(POLICY).expect("the test policy is valid") +} + +pub(crate) fn load_variant( + directory: &Path, + replace: &str, + with: &str, +) -> Result, String> { + let cases = directory.join("cases"); + std::fs::create_dir_all(cases.join("string")).expect("cases directory"); + std::fs::write( + cases.join("string").join("concat.toml"), + CASE_FILE.replacen(replace, with, 1), + ) + .expect("case file written"); + load_cases(&cases, &policy()).map_err(|error| error.to_string()) +} + +pub(crate) fn hashes(replace: &str, with: &str) -> Vec { + let directory = tempfile::tempdir().expect("temporary directory"); + load_variant(directory.path(), replace, with) + .expect("the variant loads") + .into_iter() + .map(|case| case.hash) + .collect() +} + +pub(crate) fn observed_problem( + replace: &str, + with: &str, + fragment: Option<&str>, +) -> Option { + let directory = tempfile::tempdir().expect("temporary directory"); + let message = load_variant(directory.path(), replace, with).err()?; + Some( + fragment + .filter(|fragment| message.contains(fragment)) + .map_or(message.clone(), str::to_owned), + ) +} + +pub(crate) fn source_sql_of(reference: &str) -> Option { + let directory = tempfile::tempdir().expect("temporary directory"); + load_variant(directory.path(), "", "") + .expect("the case file loads") + .into_iter() + .find(|case| case.reference == reference) + .map(|case| case.source_sql) +} diff --git a/crates/sqlquack-evidence/src/cases/tests/test_case_hash.rs b/crates/sqlquack-evidence/src/cases/tests/test_case_hash.rs new file mode 100644 index 000000000..9134f46d0 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/tests/test_case_hash.rs @@ -0,0 +1,48 @@ +use crate::cases::tests::helpers::hashes; +use crate::cases::tests::test_types::HashTestCase; + +#[test] +fn given_case_edit_when_hashing_then_only_result_changing_edits_change_the_hash() { + let baseline = hashes("", ""); + let test_cases = [ + HashTestCase { + description: "an unchanged file hashes the same", + replace: "", + with: "", + expected_same_hash: true, + }, + HashTestCase { + description: "a new description keeps the hash", + replace: "CONCAT with NULL and empty inputs.", + with: "Another description.", + expected_same_hash: true, + }, + HashTestCase { + description: "a new expression changes the hash", + replace: "expression = \"CONCAT(a, b)\"", + with: "expression = \"CONCAT(b, a)\"", + expected_same_hash: false, + }, + HashTestCase { + description: "a changed input value changes the hash", + replace: "[\"'gift'\", \"NULL\"]", + with: "[\"'gift'\", \"'tag'\"]", + expected_same_hash: false, + }, + HashTestCase { + description: "another session profile changes the hash", + replace: "session_profile = \"utc-v1\"", + with: "session_profile = \"utc-v2\"", + expected_same_hash: false, + }, + ]; + + for test_case in test_cases { + assert_eq!( + hashes(test_case.replace, test_case.with)[0] == baseline[0], + test_case.expected_same_hash, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-evidence/src/cases/tests/test_load_cases.rs b/crates/sqlquack-evidence/src/cases/tests/test_load_cases.rs new file mode 100644 index 000000000..0ab49f570 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/tests/test_load_cases.rs @@ -0,0 +1,103 @@ +use crate::cases::tests::helpers::{observed_problem, source_sql_of}; +use crate::cases::tests::test_types::{LoadTestCase, SourceSqlTestCase}; + +#[test] +fn given_case_file_when_loading_then_format_and_policy_rules_are_enforced() { + let test_cases = [ + LoadTestCase { + description: "the base file loads", + replace: "", + with: "", + expected_problem: None, + }, + LoadTestCase { + description: "an unknown key is rejected", + replace: "tags = [\"null\", \"empty\"]", + with: "tags = [\"null\", \"empty\"]\ntolerance = 1", + expected_problem: Some("unknown field `tolerance`"), + }, + LoadTestCase { + description: "a category's required inputs must be covered", + replace: "tags = [\"null\", \"empty\"]", + with: "tags = [\"null\"]", + expected_problem: Some("category `string` requires inputs no case covers: empty"), + }, + LoadTestCase { + description: "an unknown category is rejected", + replace: "category = \"string\"", + with: "category = \"dates\"", + expected_problem: Some("unknown category `dates`"), + }, + LoadTestCase { + description: "an unknown session profile is rejected", + replace: "session_profile = \"utc-v1\"", + with: "session_profile = \"local\"", + expected_problem: Some("unknown session profile `local`"), + }, + LoadTestCase { + description: "a query needs a declared row order", + replace: "order = \"ordered\"", + with: "", + expected_problem: Some("a query needs `order = \"ordered\"` or `\"bag\"`"), + }, + LoadTestCase { + description: "rows need one value per column", + replace: "[\"''\", \"'wrap'\"]", + with: "[\"''\"]", + expected_problem: Some("every row needs one value per column"), + }, + LoadTestCase { + description: "case ids are unique per file", + replace: "id = \"sorted\"", + with: "id = \"concat\"", + expected_problem: Some("repeats a case id"), + }, + ]; + + for test_case in test_cases { + assert_eq!( + observed_problem( + test_case.replace, + test_case.with, + test_case.expected_problem + ) + .as_deref(), + test_case.expected_problem, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_case_when_expanding_then_inputs_become_typed_rows_before_the_case_sql() { + let test_cases = [ + SourceSqlTestCase { + description: "an expression is evaluated per row in input order", + reference: "string/concat.toml#concat", + expected_sql: "WITH input AS (\n \ + SELECT 1 AS ordinal, CAST('gift' AS VARCHAR) AS a, CAST(NULL AS VARCHAR) AS b\n \ + UNION ALL\n \ + SELECT 2 AS ordinal, CAST('' AS VARCHAR) AS a, CAST('wrap' AS VARCHAR) AS b\n)\n\ + SELECT ordinal, CONCAT(a, b) AS value\nFROM input\nORDER BY ordinal", + }, + SourceSqlTestCase { + description: "a query follows the input relation unchanged", + reference: "string/concat.toml#sorted", + expected_sql: "WITH input AS (\n \ + SELECT 1 AS ordinal, CAST('b' AS VARCHAR) AS a\n \ + UNION ALL\n \ + SELECT 2 AS ordinal, CAST('a' AS VARCHAR) AS a\n)\n\ + SELECT a FROM input ORDER BY a", + }, + ]; + + for test_case in test_cases { + assert_eq!( + source_sql_of(test_case.reference).as_deref(), + Some(test_case.expected_sql), + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-evidence/src/cases/tests/test_types.rs b/crates/sqlquack-evidence/src/cases/tests/test_types.rs new file mode 100644 index 000000000..035aaba54 --- /dev/null +++ b/crates/sqlquack-evidence/src/cases/tests/test_types.rs @@ -0,0 +1,19 @@ +pub(crate) struct HashTestCase { + pub(crate) description: &'static str, + pub(crate) replace: &'static str, + pub(crate) with: &'static str, + pub(crate) expected_same_hash: bool, +} + +pub(crate) struct LoadTestCase { + pub(crate) description: &'static str, + pub(crate) replace: &'static str, + pub(crate) with: &'static str, + pub(crate) expected_problem: Option<&'static str>, +} + +pub(crate) struct SourceSqlTestCase { + pub(crate) description: &'static str, + pub(crate) reference: &'static str, + pub(crate) expected_sql: &'static str, +} diff --git a/crates/sqlquack-evidence/src/constants.rs b/crates/sqlquack-evidence/src/constants.rs new file mode 100644 index 000000000..6e9a34405 --- /dev/null +++ b/crates/sqlquack-evidence/src/constants.rs @@ -0,0 +1,31 @@ +//! Evidence layout and formats. + +/// Authored case files, relative to the repository root. +pub const CASES_DIR: &str = "cases"; + +/// Recorded results: append-only, one `.json` and usually one `.arrow` per case. +pub const RESULTS_DIR: &str = "evidence/results"; + +/// Pending case files for the recorder; derived, never committed. +pub const PENDING_DIR: &str = "evidence/pending"; + +/// Pending audit cases; derived, never committed. +pub const AUDIT_PENDING_DIR: &str = "evidence/audit/pending"; + +/// Fresh audit recordings; never committed. +pub const AUDIT_RESULTS_DIR: &str = "evidence/audit/results"; + +/// The version of the case, pending and result file formats. +pub const EVIDENCE_FORMAT: u32 = 1; + +/// The source dialect name recorded in hashes and pending files. +pub const SOURCE_DIALECT: &str = "snowflake"; + +/// The result column holding the input row number in expression cases. +pub const ORDINAL_COLUMN: &str = "ordinal"; + +/// The result column holding the expression value in expression cases. +pub const VALUE_COLUMN: &str = "value"; + +/// The relation that holds the case input rows. +pub const INPUT_RELATION: &str = "input"; diff --git a/crates/sqlquack-evidence/src/errors.rs b/crates/sqlquack-evidence/src/errors.rs new file mode 100644 index 000000000..11ebc362d --- /dev/null +++ b/crates/sqlquack-evidence/src/errors.rs @@ -0,0 +1,31 @@ +//! Evidence failures. + +use std::fmt::{self, Display}; + +/// Why evidence could not be read or written. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EvidenceError { + /// A file could not be read or written. + Io(String), + /// Case files break the case format or the policy; every problem is listed. + InvalidCases(Vec), + /// A stored result is malformed or does not match its name or contents. + InvalidResult(String), + /// DuckDB could not be started. + DuckDb(String), +} + +impl Display for EvidenceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(message) => formatter.write_str(message), + Self::InvalidCases(problems) => { + write!(formatter, "invalid case files: {}", problems.join("; ")) + } + Self::InvalidResult(message) => write!(formatter, "invalid recorded result: {message}"), + Self::DuckDb(message) => write!(formatter, "DuckDB failed: {message}"), + } + } +} + +impl std::error::Error for EvidenceError {} diff --git a/crates/sqlquack-evidence/src/lib.rs b/crates/sqlquack-evidence/src/lib.rs index 179adb7b4..3d87c9b0f 100644 --- a/crates/sqlquack-evidence/src/lib.rs +++ b/crates/sqlquack-evidence/src/lib.rs @@ -1 +1,12 @@ -//! placeholder +//! Evidence: cases, content hashes, pending files, recorded results, DuckDB replay and audits. + +#![forbid(unsafe_code)] + +pub mod audit; +pub mod cases; +pub mod constants; +pub mod errors; +pub mod pending; +pub mod replay; +pub mod results; +pub mod verify; diff --git a/crates/sqlquack-evidence/src/pending/main/mod.rs b/crates/sqlquack-evidence/src/pending/main/mod.rs new file mode 100644 index 000000000..2b244d836 --- /dev/null +++ b/crates/sqlquack-evidence/src/pending/main/mod.rs @@ -0,0 +1 @@ +pub mod write_pending; diff --git a/crates/sqlquack-evidence/src/pending/main/write_pending.rs b/crates/sqlquack-evidence/src/pending/main/write_pending.rs new file mode 100644 index 000000000..9f97f1c51 --- /dev/null +++ b/crates/sqlquack-evidence/src/pending/main/write_pending.rs @@ -0,0 +1,66 @@ +//! `write_pending`: a pending file for every case without a recorded result. + +use std::collections::BTreeSet; +use std::path::Path; + +use crate::cases::models::LoadedCase; +use crate::constants::{EVIDENCE_FORMAT, SOURCE_DIALECT}; +use crate::errors::EvidenceError; +use crate::pending::models::{PendingCase, PendingSummary}; + +/// Writes `.json` into `out` for each unrecorded case and removes stale pending files. +pub fn write_pending( + cases: &[LoadedCase], + results: &Path, + out: &Path, + duckdb_version: &str, +) -> Result { + std::fs::create_dir_all(out) + .map_err(|error| EvidenceError::Io(format!("{}: {error}", out.display())))?; + let mut summary = PendingSummary::default(); + let mut wanted: BTreeSet = BTreeSet::new(); + for case in cases { + if results.join(format!("{}.json", case.hash)).exists() { + summary.recorded += 1; + continue; + } + let pending = PendingCase { + format: EVIDENCE_FORMAT, + case_hash: case.hash.clone(), + case_reference: case.reference.clone(), + source_dialect: SOURCE_DIALECT.to_owned(), + session_parameters: case.session_parameters.clone(), + source_sql: case.source_sql.clone(), + duckdb_version: duckdb_version.to_owned(), + }; + let name = format!("{}.json", case.hash); + let text = serde_json::to_string_pretty(&pending) + .map_err(|error| EvidenceError::Io(error.to_string()))?; + std::fs::write(out.join(&name), text + "\n") + .map_err(|error| EvidenceError::Io(format!("{name}: {error}")))?; + wanted.insert(name); + summary.written += 1; + } + summary.removed = remove_stale(out, &wanted)?; + Ok(summary) +} + +fn remove_stale(out: &Path, wanted: &BTreeSet) -> Result { + let entries = std::fs::read_dir(out) + .map_err(|error| EvidenceError::Io(format!("{}: {error}", out.display())))?; + let mut removed = 0; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let is_pending_file = name.len() == 69 + && name.ends_with(".json") + && name[..64] + .chars() + .all(|character| character.is_ascii_hexdigit()); + if is_pending_file && !wanted.contains(&name) { + std::fs::remove_file(entry.path()) + .map_err(|error| EvidenceError::Io(format!("{name}: {error}")))?; + removed += 1; + } + } + Ok(removed) +} diff --git a/crates/sqlquack-evidence/src/pending/mod.rs b/crates/sqlquack-evidence/src/pending/mod.rs new file mode 100644 index 000000000..7583de720 --- /dev/null +++ b/crates/sqlquack-evidence/src/pending/mod.rs @@ -0,0 +1,4 @@ +//! Pending case files: what the recorder still has to run. + +pub mod main; +pub mod models; diff --git a/crates/sqlquack-evidence/src/pending/models.rs b/crates/sqlquack-evidence/src/pending/models.rs new file mode 100644 index 000000000..15bfd6626 --- /dev/null +++ b/crates/sqlquack-evidence/src/pending/models.rs @@ -0,0 +1,37 @@ +//! The pending case file the recorder reads. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use sqlquack_policy::policy::models::SessionValue; + +/// One case for the recorder to run, written as `.json`. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PendingCase { + /// The evidence format. + pub format: u32, + /// The case hash naming the result to write. + pub case_hash: String, + /// `#` of the authored case. + pub case_reference: String, + /// The source dialect. + pub source_dialect: String, + /// Session parameters to set before running the SQL. + pub session_parameters: BTreeMap, + /// The SQL to run. + pub source_sql: String, + /// The DuckDB version the case is prepared against. + pub duckdb_version: String, +} + +/// What `write_pending` did. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PendingSummary { + /// Pending files written. + pub written: usize, + /// Cases that already have a result. + pub recorded: usize, + /// Stale pending files removed. + pub removed: usize, +} diff --git a/crates/sqlquack-evidence/src/replay/main/duckdb_version.rs b/crates/sqlquack-evidence/src/replay/main/duckdb_version.rs new file mode 100644 index 000000000..7f793c3a8 --- /dev/null +++ b/crates/sqlquack-evidence/src/replay/main/duckdb_version.rs @@ -0,0 +1,14 @@ +//! `duckdb_version`: the linked DuckDB's version. + +use duckdb::Connection; + +use crate::errors::EvidenceError; + +/// The version string DuckDB reports, such as `v2.0.0`. +pub fn duckdb_version() -> Result { + let connection = + Connection::open_in_memory().map_err(|error| EvidenceError::DuckDb(error.to_string()))?; + connection + .query_row("SELECT version()", [], |row| row.get::<_, String>(0)) + .map_err(|error| EvidenceError::DuckDb(error.to_string())) +} diff --git a/crates/sqlquack-evidence/src/replay/main/mod.rs b/crates/sqlquack-evidence/src/replay/main/mod.rs new file mode 100644 index 000000000..c3836a410 --- /dev/null +++ b/crates/sqlquack-evidence/src/replay/main/mod.rs @@ -0,0 +1,2 @@ +pub mod duckdb_version; +pub mod replay_on_duckdb; diff --git a/crates/sqlquack-evidence/src/replay/main/replay_on_duckdb.rs b/crates/sqlquack-evidence/src/replay/main/replay_on_duckdb.rs new file mode 100644 index 000000000..bcb3aa78c --- /dev/null +++ b/crates/sqlquack-evidence/src/replay/main/replay_on_duckdb.rs @@ -0,0 +1,25 @@ +//! `replay_on_duckdb`: run SQL in a fresh in-memory DuckDB and fetch Arrow results. + +use duckdb::Connection; +use sqlquack_compare::values::models::ResultSet; + +use crate::replay::models::ReplaySettings; + +/// The result of `sql` in a new in-memory database with `settings`, or DuckDB's error. +pub fn replay_on_duckdb(sql: &str, settings: &ReplaySettings) -> Result { + let connection = Connection::open_in_memory().map_err(|error| error.to_string())?; + connection + .execute_batch(&format!( + "SET TimeZone = '{}'; SET default_null_order = '{}';", + settings.time_zone.replace('\'', "''"), + settings.null_order.replace('\'', "''") + )) + .map_err(|error| error.to_string())?; + let mut statement = connection.prepare(sql).map_err(|error| error.to_string())?; + let arrow = statement + .query_arrow([]) + .map_err(|error| error.to_string())?; + let schema = arrow.get_schema(); + let batches = arrow.collect(); + Ok(ResultSet { schema, batches }) +} diff --git a/crates/sqlquack-evidence/src/replay/mod.rs b/crates/sqlquack-evidence/src/replay/mod.rs new file mode 100644 index 000000000..57ca420e8 --- /dev/null +++ b/crates/sqlquack-evidence/src/replay/mod.rs @@ -0,0 +1,4 @@ +//! Running converted SQL on DuckDB. + +pub mod main; +pub mod models; diff --git a/crates/sqlquack-evidence/src/replay/models.rs b/crates/sqlquack-evidence/src/replay/models.rs new file mode 100644 index 000000000..677e2999c --- /dev/null +++ b/crates/sqlquack-evidence/src/replay/models.rs @@ -0,0 +1,10 @@ +//! Replay settings. + +/// The DuckDB session a case replays in. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReplaySettings { + /// DuckDB's `TimeZone`. + pub time_zone: String, + /// DuckDB's `default_null_order`. + pub null_order: String, +} diff --git a/crates/sqlquack-evidence/src/results/_helpers/mod.rs b/crates/sqlquack-evidence/src/results/_helpers/mod.rs new file mode 100644 index 000000000..864940733 --- /dev/null +++ b/crates/sqlquack-evidence/src/results/_helpers/mod.rs @@ -0,0 +1 @@ +pub(crate) mod reading; diff --git a/crates/sqlquack-evidence/src/results/_helpers/reading.rs b/crates/sqlquack-evidence/src/results/_helpers/reading.rs new file mode 100644 index 000000000..0a6f45941 --- /dev/null +++ b/crates/sqlquack-evidence/src/results/_helpers/reading.rs @@ -0,0 +1,80 @@ +//! Reading metadata and Arrow IPC files. + +use std::io::Cursor; +use std::path::Path; + +use arrow::ipc::reader::FileReader; +use arrow::record_batch::RecordBatch; +use sha2::{Digest, Sha256}; +use sqlquack_compare::comparison::models::{RecordedColumn, RecordedResult}; +use sqlquack_compare::values::models::ResultSet; + +use crate::constants::EVIDENCE_FORMAT; +use crate::errors::EvidenceError; +use crate::results::main::snowflake_type::snowflake_type; +use crate::results::models::ResultMetadata; + +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub(crate) fn read_metadata(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|error| EvidenceError::InvalidResult(format!("{}: {error}", path.display())))?; + let metadata: ResultMetadata = serde_json::from_str(&text) + .map_err(|error| EvidenceError::InvalidResult(format!("{}: {error}", path.display())))?; + if metadata.format != EVIDENCE_FORMAT { + return Err(EvidenceError::InvalidResult(format!( + "{}: format {} is not {EVIDENCE_FORMAT}", + path.display(), + metadata.format + ))); + } + Ok(metadata) +} + +pub(crate) fn read_arrow(hash: &str, bytes: Vec) -> Result { + let reader = FileReader::try_new(Cursor::new(bytes), None).map_err(|error| { + EvidenceError::InvalidResult(format!("{hash}: not an Arrow IPC file: {error}")) + })?; + let schema = reader.schema(); + let batches = reader + .collect::, _>>() + .map_err(|error| EvidenceError::InvalidResult(format!("{hash}: {error}")))?; + Ok(ResultSet { schema, batches }) +} + +pub(crate) fn recorded_result( + metadata: &ResultMetadata, + rows: ResultSet, +) -> Result { + if rows.schema.fields().len() != metadata.columns.len() { + return Err(EvidenceError::InvalidResult(format!( + "{}: the Arrow file has {} columns, the metadata describes {}", + metadata.case_hash, + rows.schema.fields().len(), + metadata.columns.len() + ))); + } + let row_count: usize = rows.batches.iter().map(RecordBatch::num_rows).sum(); + if metadata.row_count != Some(row_count as u64) { + return Err(EvidenceError::InvalidResult(format!( + "{}: the Arrow file has {row_count} rows, the metadata says {:?}", + metadata.case_hash, metadata.row_count + ))); + } + Ok(RecordedResult { + columns: metadata + .columns + .iter() + .map(|column| RecordedColumn { + name: column.name.clone(), + snowflake_type: snowflake_type(column), + }) + .collect(), + rows, + }) +} diff --git a/crates/sqlquack-evidence/src/results/main/load_result.rs b/crates/sqlquack-evidence/src/results/main/load_result.rs new file mode 100644 index 000000000..73d858c98 --- /dev/null +++ b/crates/sqlquack-evidence/src/results/main/load_result.rs @@ -0,0 +1,50 @@ +//! `load_result`: one stored result, checked against its name and its Arrow file. + +use std::path::Path; + +use crate::errors::EvidenceError; +use crate::results::_helpers::reading::{read_arrow, read_metadata, recorded_result, sha256_hex}; +use crate::results::models::{RecordedOutcome, StoredResult}; + +/// The result named `hash` in `directory`, `None` when nothing is recorded yet. +pub fn load_result(directory: &Path, hash: &str) -> Result, EvidenceError> { + let metadata_path = directory.join(format!("{hash}.json")); + let arrow_path = directory.join(format!("{hash}.arrow")); + if !metadata_path.exists() { + if arrow_path.exists() { + return Err(EvidenceError::InvalidResult(format!( + "{hash}: an Arrow file without metadata" + ))); + } + return Ok(None); + } + let metadata = read_metadata(&metadata_path)?; + if metadata.case_hash != hash { + return Err(EvidenceError::InvalidResult(format!( + "{hash}: the metadata names case {}", + metadata.case_hash + ))); + } + let rows = match metadata.outcome { + RecordedOutcome::Error => { + if arrow_path.exists() || metadata.error.is_none() { + return Err(EvidenceError::InvalidResult(format!( + "{hash}: an error outcome needs an error and no Arrow file" + ))); + } + None + } + RecordedOutcome::Rows => { + let bytes = std::fs::read(&arrow_path).map_err(|error| { + EvidenceError::InvalidResult(format!("{hash}: cannot read the Arrow file: {error}")) + })?; + if metadata.arrow_sha256.as_deref() != Some(sha256_hex(&bytes).as_str()) { + return Err(EvidenceError::InvalidResult(format!( + "{hash}: the Arrow file does not match the hash in its metadata" + ))); + } + Some(recorded_result(&metadata, read_arrow(hash, bytes)?)?) + } + }; + Ok(Some(StoredResult { metadata, rows })) +} diff --git a/crates/sqlquack-evidence/src/results/main/mod.rs b/crates/sqlquack-evidence/src/results/main/mod.rs new file mode 100644 index 000000000..0845d623c --- /dev/null +++ b/crates/sqlquack-evidence/src/results/main/mod.rs @@ -0,0 +1,2 @@ +pub mod load_result; +pub mod snowflake_type; diff --git a/crates/sqlquack-evidence/src/results/main/snowflake_type.rs b/crates/sqlquack-evidence/src/results/main/snowflake_type.rs new file mode 100644 index 000000000..0b3491dc8 --- /dev/null +++ b/crates/sqlquack-evidence/src/results/main/snowflake_type.rs @@ -0,0 +1,20 @@ +//! `snowflake_type`: a warehouse column description as a Snowflake type name. + +use crate::results::models::WarehouseColumn; + +/// `NUMBER(38,0)`, `VARCHAR(16777216)`, `TIMESTAMP_NTZ(9)` and so on. +pub fn snowflake_type(column: &WarehouseColumn) -> String { + let precision = column.precision.unwrap_or_default(); + let scale = column.scale.unwrap_or_default(); + let size = column.internal_size.unwrap_or_default(); + match column.type_name.as_str() { + "FIXED" => format!("NUMBER({precision},{scale})"), + "REAL" => "FLOAT".to_owned(), + "TEXT" => format!("VARCHAR({size})"), + "BINARY" => format!("BINARY({size})"), + "TIME" | "TIMESTAMP_NTZ" | "TIMESTAMP_LTZ" | "TIMESTAMP_TZ" => { + format!("{}({scale})", column.type_name) + } + other => other.to_owned(), + } +} diff --git a/crates/sqlquack-evidence/src/results/mod.rs b/crates/sqlquack-evidence/src/results/mod.rs new file mode 100644 index 000000000..04d957105 --- /dev/null +++ b/crates/sqlquack-evidence/src/results/mod.rs @@ -0,0 +1,5 @@ +//! Recorded results: Arrow IPC values plus recorder metadata, named by case hash. + +pub(crate) mod _helpers; +pub mod main; +pub mod models; diff --git a/crates/sqlquack-evidence/src/results/models.rs b/crates/sqlquack-evidence/src/results/models.rs new file mode 100644 index 000000000..39e7c5145 --- /dev/null +++ b/crates/sqlquack-evidence/src/results/models.rs @@ -0,0 +1,92 @@ +//! Result metadata, as the recorder writes it. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use sqlquack_compare::comparison::models::RecordedResult; +use sqlquack_policy::policy::models::SessionValue; + +/// The metadata file beside each recorded result. Unknown keys are rejected. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ResultMetadata { + /// The evidence format. + pub format: u32, + /// The case hash; also the file name. + pub case_hash: String, + /// Whether the warehouse returned rows or an error. + pub outcome: RecordedOutcome, + /// The recorder name and version. + pub recorder: String, + /// The warehouse client name and version. + pub connector: String, + /// The warehouse query ID. + pub query_id: Option, + /// The warehouse version. + pub warehouse_version: String, + /// The DuckDB version the case was prepared against. + pub duckdb_version: String, + /// When the result was recorded, in UTC, RFC 3339. + pub recorded_at: String, + /// The session parameters the result was recorded under. + pub session_parameters: BTreeMap, + /// The result columns as the warehouse described them. + pub columns: Vec, + /// Rows returned. + pub row_count: Option, + /// SHA-256 of the Arrow IPC file, as lower-case hex. + pub arrow_sha256: Option, + /// The warehouse error, for error outcomes. + pub error: Option, +} + +/// Whether the warehouse returned rows or an error. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RecordedOutcome { + /// Rows, in the Arrow file. + Rows, + /// An error; there is no Arrow file. + Error, +} + +/// One result column as the Snowflake connector describes it. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WarehouseColumn { + /// The column name. + pub name: String, + /// The connector's numeric type code. + pub type_code: i64, + /// The connector's type name, such as `FIXED` or `TEXT`. + pub type_name: String, + /// Precision, for numbers. + pub precision: Option, + /// Scale, for numbers and time types. + pub scale: Option, + /// Length, for text and binary. + pub internal_size: Option, + /// Whether NULL is allowed. + pub nullable: Option, +} + +/// A warehouse error. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RecordedError { + /// The warehouse error number. + pub errno: Option, + /// The SQLSTATE. + pub sqlstate: Option, + /// The message. + pub message: String, +} + +/// A loaded, integrity-checked result. +#[derive(Clone, Debug)] +pub struct StoredResult { + /// Its metadata. + pub metadata: ResultMetadata, + /// Its rows, absent for error outcomes. + pub rows: Option, +} diff --git a/crates/sqlquack-evidence/src/verify/_helpers/judge.rs b/crates/sqlquack-evidence/src/verify/_helpers/judge.rs new file mode 100644 index 000000000..0f98044ad --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/_helpers/judge.rs @@ -0,0 +1,80 @@ +//! The verdict for one case. + +use std::path::Path; + +use sqlquack_compare::comparison::main::compare_with_replay::compare_with_replay; +use sqlquack_policy::policy::models::Policy; + +use crate::cases::models::LoadedCase; +use crate::replay::main::replay_on_duckdb::replay_on_duckdb; +use crate::replay::models::ReplaySettings; +use crate::results::main::load_result::load_result; +use crate::results::models::StoredResult; +use crate::verify::models::{Conversion, Verdict}; +use crate::verify::types::CaseConverter; + +pub(crate) fn judge( + case: &LoadedCase, + results: &Path, + policy: &Policy, + converter: &dyn CaseConverter, +) -> Verdict { + let stored = match load_result(results, &case.hash) { + Ok(Some(stored)) => stored, + Ok(None) => return Verdict::Pending, + Err(error) => { + return Verdict::Failed { + reason: error.to_string(), + }; + } + }; + let duckdb_sql = match converter.convert(case) { + Conversion::Converted(sql) => sql, + Conversion::Refused(reasons) => return Verdict::Refused { reasons }, + }; + let settings = ReplaySettings { + time_zone: policy + .session_profiles + .get(&case.session_profile) + .map_or_else(String::new, |profile| profile.duckdb_time_zone.clone()), + null_order: policy.comparator.null_order.duckdb_setting().to_owned(), + }; + replayed_verdict( + case, + &stored, + replay_on_duckdb(&duckdb_sql, &settings), + policy, + ) +} + +fn replayed_verdict( + case: &LoadedCase, + stored: &StoredResult, + replayed: Result, + policy: &Policy, +) -> Verdict { + match (&stored.rows, replayed) { + (None, Err(_)) => Verdict::Passed, + (None, Ok(_)) => Verdict::Failed { + reason: format!( + "Snowflake raised an error ({}), but DuckDB returned rows", + stored + .metadata + .error + .as_ref() + .map_or("unknown", |error| error.message.as_str()) + ), + }, + (Some(_), Err(error)) => Verdict::Failed { + reason: format!("Snowflake returned rows, but DuckDB failed: {error}"), + }, + (Some(recorded), Ok(replayed)) => { + match compare_with_replay(recorded, &replayed, case.order, &policy.comparator) { + Ok(()) => Verdict::Passed, + Err(mismatch) => Verdict::Failed { + reason: mismatch.to_string(), + }, + } + } + } +} diff --git a/crates/sqlquack-evidence/src/verify/_helpers/listing.rs b/crates/sqlquack-evidence/src/verify/_helpers/listing.rs new file mode 100644 index 000000000..85b0401d7 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/_helpers/listing.rs @@ -0,0 +1,25 @@ +//! The hashes recorded in a results directory. + +use std::path::Path; + +use crate::errors::EvidenceError; + +pub(crate) fn recorded_hashes(results: &Path) -> Result, EvidenceError> { + if !results.exists() { + return Ok(Vec::new()); + } + let entries = std::fs::read_dir(results) + .map_err(|error| EvidenceError::Io(format!("{}: {error}", results.display())))?; + let mut hashes: Vec = entries + .flatten() + .filter_map(|entry| { + entry + .file_name() + .to_string_lossy() + .strip_suffix(".json") + .map(str::to_owned) + }) + .collect(); + hashes.sort(); + Ok(hashes) +} diff --git a/crates/sqlquack-evidence/src/verify/_helpers/mod.rs b/crates/sqlquack-evidence/src/verify/_helpers/mod.rs new file mode 100644 index 000000000..f8a38ec56 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/_helpers/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod judge; +pub(crate) mod listing; diff --git a/crates/sqlquack-evidence/src/verify/main/mod.rs b/crates/sqlquack-evidence/src/verify/main/mod.rs new file mode 100644 index 000000000..180e54a57 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/main/mod.rs @@ -0,0 +1 @@ +pub mod verify_cases; diff --git a/crates/sqlquack-evidence/src/verify/main/verify_cases.rs b/crates/sqlquack-evidence/src/verify/main/verify_cases.rs new file mode 100644 index 000000000..4ae896783 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/main/verify_cases.rs @@ -0,0 +1,38 @@ +//! `verify_cases`: every case's verdict against its recorded result. + +use std::collections::BTreeSet; +use std::path::Path; + +use sqlquack_policy::policy::models::Policy; + +use crate::cases::models::LoadedCase; +use crate::errors::EvidenceError; +use crate::verify::_helpers::judge::judge; +use crate::verify::_helpers::listing::recorded_hashes; +use crate::verify::models::{CaseVerdict, VerifyReport}; +use crate::verify::types::CaseConverter; + +/// Converts and replays every recorded case and compares it under the policy. +pub fn verify_cases( + cases: &[LoadedCase], + results: &Path, + policy: &Policy, + converter: &dyn CaseConverter, +) -> Result { + let referenced: BTreeSet<&str> = cases.iter().map(|case| case.hash.as_str()).collect(); + let unreferenced_results = recorded_hashes(results)? + .into_iter() + .filter(|hash| !referenced.contains(hash.as_str())) + .collect(); + Ok(VerifyReport { + cases: cases + .iter() + .map(|case| CaseVerdict { + reference: case.reference.clone(), + hash: case.hash.clone(), + verdict: judge(case, results, policy, converter), + }) + .collect(), + unreferenced_results, + }) +} diff --git a/crates/sqlquack-evidence/src/verify/mod.rs b/crates/sqlquack-evidence/src/verify/mod.rs new file mode 100644 index 000000000..93d19de2b --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/mod.rs @@ -0,0 +1,9 @@ +//! Replaying recorded cases on DuckDB and comparing. + +pub(crate) mod _helpers; +pub mod main; +pub mod models; +pub mod types; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlquack-evidence/src/verify/models.rs b/crates/sqlquack-evidence/src/verify/models.rs new file mode 100644 index 000000000..b42cd7257 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/models.rs @@ -0,0 +1,53 @@ +//! Verification outcomes. + +use serde::Serialize; + +/// What a converter did with a case. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Conversion { + /// DuckDB SQL to replay. + Converted(String), + /// Refusal reasons. + Refused(Vec), +} + +/// The verdict for one case. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "verdict", rename_all = "snake_case")] +pub enum Verdict { + /// No result is recorded yet. + Pending, + /// The converter refused, which is always acceptable. + Refused { + /// Why. + reasons: Vec, + }, + /// The replay matched the recorded result. + Passed, + /// The replay differed from the recorded result, or the evidence is invalid. + Failed { + /// What went wrong. + reason: String, + }, +} + +/// One case's verdict. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CaseVerdict { + /// `#`. + pub reference: String, + /// The case hash. + pub hash: String, + /// The verdict. + #[serde(flatten)] + pub verdict: Verdict, +} + +/// Verification of every case. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct VerifyReport { + /// One verdict per case. + pub cases: Vec, + /// Recorded results no current case names; kept, because results are append-only. + pub unreferenced_results: Vec, +} diff --git a/crates/sqlquack-evidence/src/verify/tests.rs b/crates/sqlquack-evidence/src/verify/tests.rs new file mode 100644 index 000000000..64f6e93b6 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/tests.rs @@ -0,0 +1,6 @@ +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_round_trip.rs"] +mod test_round_trip; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlquack-evidence/src/verify/tests/helpers.rs b/crates/sqlquack-evidence/src/verify/tests/helpers.rs new file mode 100644 index 000000000..c45e82d6a --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/tests/helpers.rs @@ -0,0 +1,244 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::path::Path; +use std::sync::Arc; + +use arrow::array::{ArrayRef, Decimal128Array, RecordBatch}; +use arrow::datatypes::DataType; +use arrow::ipc::writer::FileWriter; +use sqlquack_compare::values::models::ResultSet; +use sqlquack_policy::policy::main::parse_policy::parse_policy; + +use crate::cases::main::load_cases::load_cases; +use crate::pending::main::write_pending::write_pending; +use crate::pending::models::PendingCase; +use crate::replay::main::replay_on_duckdb::replay_on_duckdb; +use crate::replay::models::ReplaySettings; +use crate::results::_helpers::reading::sha256_hex; +use crate::results::models::{RecordedOutcome, ResultMetadata, WarehouseColumn}; +use crate::verify::main::verify_cases::verify_cases; +use crate::verify::models::Verdict; +use crate::verify::tests::test_types::{Tampering, UnchangedSqlForTests}; + +/// Marks every stand-in result: produced by DuckDB in a test, never warehouse evidence. +const STAND_IN_RECORDER: &str = "test fixture: DuckDB stand-in, not evidence"; + +const POLICY: &str = r#" +schema_version = 1 + +[comparator] +column_names = "case_insensitive" +null_order = "nulls_last_on_asc_first_on_desc" +timestamp_tz = "instant" + +[comparator.float_ulps] +float32 = 0 +float64 = 0 + +[[comparator.type_equality]] +snowflake = "NUMBER(p,s)" +duckdb = "DECIMAL(p,s)" +reason = "same values" + +[[comparator.type_equality]] +snowflake = "NUMBER(10,0)" +duckdb = "INTEGER" +reason = "test fixture: DuckDB stand-ins describe INTEGER as NUMBER(10,0)" + +[[comparator.type_equality]] +snowflake = "VARCHAR(n)" +duckdb = "VARCHAR" +reason = "length does not change values" + +[session_profiles.utc-v1] +description = "UTC" +duckdb_time_zone = "UTC" + +[session_profiles.utc-v1.snowflake] +TIMEZONE = "UTC" + +[edge_cases.numeric] +description = "numbers" +required = ["null"] +"#; + +const CASE_FILE: &str = r#" +category = "numeric" +construct = "node:add" +session_profile = "utc-v1" + +[[case]] +id = "decimal_addition" +description = "Decimal addition with NULL." +columns = [{ name = "a", type = "DECIMAL(10,2)" }, { name = "b", type = "DECIMAL(10,2)" }] +rows = [["1.25", "2.50"], ["NULL", "1.00"], ["-3.00", "0.75"]] +expression = "a + b" +tags = ["null"] + +[[case]] +id = "sorted_bag" +description = "Values compared as a bag." +columns = [{ name = "price", type = "DECIMAL(10,2)" }] +rows = [["3.00"], ["1.00"]] +query = "SELECT price FROM input" +order = "bag" +tags = [] +"#; + +fn warehouse_column(name: &str, data_type: &DataType) -> WarehouseColumn { + let (type_name, precision, scale, internal_size) = match data_type { + DataType::Decimal128(precision, scale) => ( + "FIXED", + Some(i64::from(*precision)), + Some(i64::from(*scale)), + None, + ), + DataType::Int32 => ("FIXED", Some(10), Some(0), None), + _ => ("TEXT", None, None, Some(16_777_216)), + }; + WarehouseColumn { + name: name.to_ascii_uppercase(), + type_code: 0, + type_name: type_name.to_owned(), + precision, + scale, + internal_size, + nullable: Some(true), + } +} + +fn changed_value(rows: &ResultSet) -> Vec { + let batch = &rows.batches[0]; + let last = batch.num_columns() - 1; + let changed: ArrayRef = match batch.column(last).data_type() { + DataType::Decimal128(precision, scale) => Arc::new( + Decimal128Array::from(vec![Some(1); batch.num_rows()]) + .with_precision_and_scale(*precision, *scale) + .expect("valid decimal"), + ), + _ => batch.column(last).clone(), + }; + let mut columns: Vec = batch.columns().to_vec(); + columns[last] = changed; + vec![RecordBatch::try_new(batch.schema(), columns).expect("same schema")] +} + +fn write_arrow(path: &Path, rows: &ResultSet, batches: &[RecordBatch]) -> Vec { + let file = File::create(path).expect("arrow file created"); + let mut writer = FileWriter::try_new(file, &rows.schema).expect("arrow writer"); + for batch in batches { + writer.write(batch).expect("batch written"); + } + writer.finish().expect("arrow file finished"); + std::fs::read(path).expect("arrow file read back") +} + +fn record_stand_in(pending: &PendingCase, results: &Path, tampering: Tampering) { + let settings = ReplaySettings { + time_zone: "UTC".to_owned(), + null_order: "nulls_last_on_asc_first_on_desc".to_owned(), + }; + let rows = replay_on_duckdb(&pending.source_sql, &settings).expect("DuckDB runs the case"); + let arrow_path = results.join(format!("{}.arrow", pending.case_hash)); + let recorded_bytes = write_arrow(&arrow_path, &rows, &rows.batches); + let tampered = [Tampering::ValueChanged, Tampering::ArrowEdited].contains(&tampering); + let final_bytes = if tampered { + write_arrow(&arrow_path, &rows, &changed_value(&rows)) + } else { + recorded_bytes.clone() + }; + let hashed_bytes = if tampering == Tampering::ArrowEdited { + recorded_bytes + } else { + final_bytes + }; + let metadata = ResultMetadata { + format: 1, + case_hash: pending.case_hash.clone(), + outcome: RecordedOutcome::Rows, + recorder: STAND_IN_RECORDER.to_owned(), + connector: STAND_IN_RECORDER.to_owned(), + query_id: None, + warehouse_version: STAND_IN_RECORDER.to_owned(), + duckdb_version: pending.duckdb_version.clone(), + recorded_at: "1970-01-01T00:00:00Z".to_owned(), + session_parameters: pending.session_parameters.clone(), + columns: rows + .schema + .fields() + .iter() + .map(|field| warehouse_column(field.name(), field.data_type())) + .collect(), + row_count: Some( + rows.batches + .iter() + .map(RecordBatch::num_rows) + .sum::() as u64, + ), + arrow_sha256: Some(sha256_hex(&hashed_bytes)), + error: None, + }; + std::fs::write( + results.join(format!("{}.json", pending.case_hash)), + serde_json::to_string_pretty(&metadata).expect("metadata serialises"), + ) + .expect("metadata written"); +} + +pub(crate) struct RoundTrip { + pub(crate) pending_before: usize, + pub(crate) pending_after: usize, + pub(crate) verdicts: Vec<&'static str>, +} + +fn verdict_name(verdict: &Verdict) -> &'static str { + match verdict { + Verdict::Pending => "pending", + Verdict::Refused { .. } => "refused", + Verdict::Passed => "passed", + Verdict::Failed { .. } => "failed", + } +} + +pub(crate) fn round_trip(tampering: Tampering) -> RoundTrip { + let directory = tempfile::tempdir().expect("temporary directory"); + let root = directory.path(); + let cases_dir = root.join("cases").join("numeric"); + std::fs::create_dir_all(&cases_dir).expect("cases directory"); + std::fs::write(cases_dir.join("addition.toml"), CASE_FILE).expect("case file"); + let results = root.join("results"); + let pending_dir = root.join("pending"); + std::fs::create_dir_all(&results).expect("results directory"); + let policy = parse_policy(POLICY).expect("the test policy is valid"); + let cases = load_cases(&root.join("cases"), &policy).expect("cases load"); + let before = write_pending(&cases, &results, &pending_dir, "v2.0.0").expect("pending written"); + let mut pending: BTreeMap = BTreeMap::new(); + for entry in std::fs::read_dir(&pending_dir) + .expect("pending listed") + .flatten() + { + let text = std::fs::read_to_string(entry.path()).expect("pending read"); + let case: PendingCase = serde_json::from_str(&text).expect("pending parses"); + pending.insert(case.case_reference.clone(), case); + } + for (reference, case) in &pending { + let applied = if reference.ends_with("#decimal_addition") { + tampering + } else { + Tampering::None + }; + record_stand_in(case, &results, applied); + } + let after = write_pending(&cases, &results, &pending_dir, "v2.0.0").expect("pending rewritten"); + let report = + verify_cases(&cases, &results, &policy, &UnchangedSqlForTests).expect("verify runs"); + RoundTrip { + pending_before: before.written, + pending_after: after.written, + verdicts: report + .cases + .iter() + .map(|case| verdict_name(&case.verdict)) + .collect(), + } +} diff --git a/crates/sqlquack-evidence/src/verify/tests/test_round_trip.rs b/crates/sqlquack-evidence/src/verify/tests/test_round_trip.rs new file mode 100644 index 000000000..0bf95e2e6 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/tests/test_round_trip.rs @@ -0,0 +1,48 @@ +use crate::verify::tests::helpers::round_trip; +use crate::verify::tests::test_types::{RoundTripTestCase, Tampering}; + +#[test] +fn given_duckdb_stand_in_results_when_verifying_then_only_untampered_results_pass() { + let test_cases = [ + RoundTripTestCase { + description: "pending, stand-in recording and replay agree", + tampering: Tampering::None, + expected_pending_before: 2, + expected_pending_after: 0, + expected_verdicts: vec!["passed", "passed"], + }, + RoundTripTestCase { + description: "a changed value with a consistent Arrow hash fails the comparison", + tampering: Tampering::ValueChanged, + expected_pending_before: 2, + expected_pending_after: 0, + expected_verdicts: vec!["failed", "passed"], + }, + RoundTripTestCase { + description: "an Arrow file edited after recording fails the integrity check", + tampering: Tampering::ArrowEdited, + expected_pending_before: 2, + expected_pending_after: 0, + expected_verdicts: vec!["failed", "passed"], + }, + ]; + + for test_case in test_cases { + let observed = round_trip(test_case.tampering); + assert_eq!( + observed.pending_before, test_case.expected_pending_before, + "{}", + test_case.description + ); + assert_eq!( + observed.pending_after, test_case.expected_pending_after, + "{}", + test_case.description + ); + assert_eq!( + observed.verdicts, test_case.expected_verdicts, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-evidence/src/verify/tests/test_types.rs b/crates/sqlquack-evidence/src/verify/tests/test_types.rs new file mode 100644 index 000000000..865892065 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/tests/test_types.rs @@ -0,0 +1,31 @@ +use crate::cases::models::LoadedCase; +use crate::verify::models::Conversion; +use crate::verify::types::CaseConverter; + +/// Test-only converter: replays the source SQL unchanged, valid only for SQL both dialects share. +pub(crate) struct UnchangedSqlForTests; + +impl CaseConverter for UnchangedSqlForTests { + fn convert(&self, case: &LoadedCase) -> Conversion { + Conversion::Converted(case.source_sql.clone()) + } +} + +/// How a stand-in result is tampered with before verification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Tampering { + /// Left as recorded. + None, + /// A value changed and the metadata's Arrow hash updated to match. + ValueChanged, + /// A value changed without updating the metadata. + ArrowEdited, +} + +pub(crate) struct RoundTripTestCase { + pub(crate) description: &'static str, + pub(crate) tampering: Tampering, + pub(crate) expected_pending_before: usize, + pub(crate) expected_pending_after: usize, + pub(crate) expected_verdicts: Vec<&'static str>, +} diff --git a/crates/sqlquack-evidence/src/verify/types.rs b/crates/sqlquack-evidence/src/verify/types.rs new file mode 100644 index 000000000..9e7b10b07 --- /dev/null +++ b/crates/sqlquack-evidence/src/verify/types.rs @@ -0,0 +1,10 @@ +//! The converter seen from verification. + +use crate::cases::models::LoadedCase; +use crate::verify::models::Conversion; + +/// Turns a case's source SQL into DuckDB SQL, or refuses. +pub trait CaseConverter { + /// The conversion of `case`. + fn convert(&self, case: &LoadedCase) -> Conversion; +} diff --git a/crates/sqlquack/Cargo.toml b/crates/sqlquack/Cargo.toml index 47af6f4f2..fcb4d745e 100644 --- a/crates/sqlquack/Cargo.toml +++ b/crates/sqlquack/Cargo.toml @@ -24,6 +24,8 @@ serde_json.workspace = true sqlquack-compare.workspace = true sqlquack-core.workspace = true sqlquack-duckdb.workspace = true +sqlquack-evidence.workspace = true +sqlquack-policy.workspace = true sqlquack-rules.workspace = true sqlquack-snowflake.workspace = true walkdir.workspace = true diff --git a/crates/sqlquack/src/analysis/models.rs b/crates/sqlquack/src/analysis/models.rs index 8096f76f7..604d89a98 100644 --- a/crates/sqlquack/src/analysis/models.rs +++ b/crates/sqlquack/src/analysis/models.rs @@ -1,9 +1,18 @@ //! Pipeline results. +use std::collections::BTreeSet; + use serde::Serialize; use sqlquack_core::classification::models::Occurrence; +use sqlquack_core::parsing::models::SourceDialect; use sqlquack_core::refusals::models::Refusal; use sqlquack_core::reparse::models::FidelityOutcome; +use sqlquack_evidence::cases::models::LoadedCase; +use sqlquack_evidence::verify::models::Conversion; +use sqlquack_evidence::verify::types::CaseConverter; +use sqlquack_rules::catalogue::main::builtin_catalogue::builtin_catalogue; + +use crate::analysis::main::analyze::analyze; /// What the pipeline found in one SQL input. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -19,3 +28,28 @@ pub struct Analysis { /// Every refusal; the input converts only when this is empty. pub refusals: Vec, } + +/// The production converter: the SQLQuack pipeline with the built-in rule catalogue. +#[derive(Clone, Copy, Debug, Default)] +pub struct PipelineConverter; + +impl CaseConverter for PipelineConverter { + fn convert(&self, case: &LoadedCase) -> Conversion { + let analysis = analyze( + &case.source_sql, + SourceDialect::Snowflake, + &builtin_catalogue(), + ); + let reasons: BTreeSet = analysis + .refusals + .iter() + .map(|refusal| format!("{}: {}", refusal.category, refusal.reason)) + .collect(); + if reasons.is_empty() { + return Conversion::Refused(vec![ + "no rule emits DuckDB SQL for this case yet".to_owned(), + ]); + } + Conversion::Refused(reasons.into_iter().collect()) + } +} diff --git a/crates/sqlquack/src/cli/_helpers/audit_command.rs b/crates/sqlquack/src/cli/_helpers/audit_command.rs new file mode 100644 index 000000000..78f0416cf --- /dev/null +++ b/crates/sqlquack/src/cli/_helpers/audit_command.rs @@ -0,0 +1,133 @@ +//! `sqlquack audit`. + +use std::io::Write; +use std::process::ExitCode; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sqlquack_evidence::audit::main::compare_audit::compare_audit; +use sqlquack_evidence::audit::main::sample_cases::sample_cases; +use sqlquack_evidence::audit::models::AuditOutcome; +use sqlquack_evidence::cases::models::LoadedCase; +use sqlquack_evidence::pending::main::write_pending::write_pending; +use sqlquack_evidence::replay::main::duckdb_version::duckdb_version; + +use crate::cli::_helpers::evidence::{Evidence, load_evidence}; +use crate::cli::models::AuditArguments; + +pub(crate) fn audit_command( + arguments: &AuditArguments, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let Some(evidence) = load_evidence("audit", &arguments.paths, stderr) else { + return ExitCode::FAILURE; + }; + match &arguments.sample { + Some(sample) => sample_command(arguments, sample, &evidence, stderr), + None => compare_command(arguments, &evidence, stdout, stderr), + } +} + +fn sample_command( + arguments: &AuditArguments, + sample: &str, + evidence: &Evidence, + stderr: &mut dyn Write, +) -> ExitCode { + let Some(percent) = sample + .trim() + .strip_suffix('%') + .and_then(|number| number.trim().parse::().ok()) + .filter(|percent| (0.0..=100.0).contains(percent)) + else { + let _ = writeln!( + stderr, + "sqlquack audit: --sample needs a percentage such as 10%" + ); + return ExitCode::from(2); + }; + let seed = arguments.seed.unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) + }); + let chosen: Vec = + sample_cases(&evidence.cases, &arguments.paths.results, percent, seed) + .into_iter() + .cloned() + .collect(); + let written = duckdb_version().and_then(|version| { + write_pending(&chosen, &arguments.audit_results, &arguments.out, &version) + }); + match written { + Ok(summary) => { + let _ = writeln!( + stderr, + "sqlquack audit: done, sampled {} recorded cases with seed {seed}; {} pending case files in {}", + chosen.len(), + summary.written, + arguments.out.display() + ); + ExitCode::SUCCESS + } + Err(error) => { + let _ = writeln!(stderr, "sqlquack audit: failed: {error}"); + ExitCode::FAILURE + } + } +} + +fn compare_command( + arguments: &AuditArguments, + evidence: &Evidence, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let entries = match compare_audit( + &evidence.cases, + &arguments.paths.results, + &arguments.audit_results, + ) { + Ok(entries) => entries, + Err(error) => { + let _ = writeln!(stderr, "sqlquack audit: failed: {error}"); + return ExitCode::FAILURE; + } + }; + let rendered = if arguments.json { + serde_json::to_string_pretty(&entries).unwrap_or_default() + "\n" + } else { + entries + .iter() + .map(|entry| { + let outcome = match &entry.outcome { + AuditOutcome::Matched => "matched".to_owned(), + AuditOutcome::Differed { detail } => format!("DIFFERED: {detail}"), + AuditOutcome::Invalid { detail } => format!("INVALID: {detail}"), + }; + format!( + "{} {outcome} (warehouse {} then {})\n", + entry.reference.as_deref().unwrap_or(&entry.hash), + entry.stored_warehouse_version.as_deref().unwrap_or("?"), + entry.fresh_warehouse_version.as_deref().unwrap_or("?") + ) + }) + .collect() + }; + let _ = stdout.write_all(rendered.as_bytes()); + let problems = entries + .iter() + .filter(|entry| entry.outcome != AuditOutcome::Matched) + .count(); + let _ = writeln!( + stderr, + "sqlquack audit: {}, {} re-recorded cases, {problems} differ or are invalid", + if problems == 0 { "done" } else { "FAILED" }, + entries.len() + ); + if problems == 0 { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} diff --git a/crates/sqlquack/src/cli/_helpers/evidence.rs b/crates/sqlquack/src/cli/_helpers/evidence.rs new file mode 100644 index 000000000..2ae73f41c --- /dev/null +++ b/crates/sqlquack/src/cli/_helpers/evidence.rs @@ -0,0 +1,31 @@ +//! Loading the policy and cases for evidence commands. + +use std::io::Write; + +use sqlquack_evidence::cases::main::load_cases::load_cases; +use sqlquack_evidence::cases::models::LoadedCase; +use sqlquack_policy::policy::main::load_policy::load_policy; +use sqlquack_policy::policy::models::Policy; + +use crate::cli::models::EvidencePaths; + +pub(crate) struct Evidence { + pub(crate) policy: Policy, + pub(crate) cases: Vec, +} + +pub(crate) fn load_evidence( + command: &str, + paths: &EvidencePaths, + stderr: &mut dyn Write, +) -> Option { + let _ = writeln!(stderr, "sqlquack {command}: loading the policy and cases…"); + let policy = load_policy(&paths.policy) + .map_err(|error| writeln!(stderr, "sqlquack {command}: failed: {error}")) + .ok()?; + let cases = load_cases(&paths.cases, &policy) + .map_err(|error| writeln!(stderr, "sqlquack {command}: failed: {error}")) + .ok()?; + let _ = writeln!(stderr, "sqlquack {command}: loaded {} cases", cases.len()); + Some(Evidence { policy, cases }) +} diff --git a/crates/sqlquack/src/cli/_helpers/mod.rs b/crates/sqlquack/src/cli/_helpers/mod.rs index 4342abca6..57b6ec433 100644 --- a/crates/sqlquack/src/cli/_helpers/mod.rs +++ b/crates/sqlquack/src/cli/_helpers/mod.rs @@ -1 +1,5 @@ +pub(crate) mod audit_command; pub(crate) mod census_command; +pub(crate) mod evidence; +pub(crate) mod pending_command; +pub(crate) mod verify_command; diff --git a/crates/sqlquack/src/cli/_helpers/pending_command.rs b/crates/sqlquack/src/cli/_helpers/pending_command.rs new file mode 100644 index 000000000..b27fb572c --- /dev/null +++ b/crates/sqlquack/src/cli/_helpers/pending_command.rs @@ -0,0 +1,41 @@ +//! `sqlquack pending`. + +use std::io::Write; +use std::process::ExitCode; + +use sqlquack_evidence::pending::main::write_pending::write_pending; +use sqlquack_evidence::replay::main::duckdb_version::duckdb_version; + +use crate::cli::_helpers::evidence::load_evidence; +use crate::cli::models::PendingArguments; + +pub(crate) fn pending_command(arguments: &PendingArguments, stderr: &mut dyn Write) -> ExitCode { + let Some(evidence) = load_evidence("pending", &arguments.paths, stderr) else { + return ExitCode::FAILURE; + }; + let written = duckdb_version().and_then(|version| { + write_pending( + &evidence.cases, + &arguments.paths.results, + &arguments.out, + &version, + ) + }); + match written { + Ok(summary) => { + let _ = writeln!( + stderr, + "sqlquack pending: done, {} pending case files in {} ({} cases already recorded, {} stale files removed)", + summary.written, + arguments.out.display(), + summary.recorded, + summary.removed + ); + ExitCode::SUCCESS + } + Err(error) => { + let _ = writeln!(stderr, "sqlquack pending: failed: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/sqlquack/src/cli/_helpers/verify_command.rs b/crates/sqlquack/src/cli/_helpers/verify_command.rs new file mode 100644 index 000000000..57c3bee2c --- /dev/null +++ b/crates/sqlquack/src/cli/_helpers/verify_command.rs @@ -0,0 +1,98 @@ +//! `sqlquack verify`. + +use std::io::Write; +use std::process::ExitCode; + +use sqlquack_evidence::verify::main::verify_cases::verify_cases; +use sqlquack_evidence::verify::models::{Verdict, VerifyReport}; + +use crate::analysis::models::PipelineConverter; +use crate::cli::_helpers::evidence::load_evidence; +use crate::cli::models::VerifyArguments; + +pub(crate) fn verify_command( + arguments: &VerifyArguments, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let Some(evidence) = load_evidence("verify", &arguments.paths, stderr) else { + return ExitCode::FAILURE; + }; + let _ = writeln!( + stderr, + "sqlquack verify: replaying recorded cases on DuckDB…" + ); + let report = match verify_cases( + &evidence.cases, + &arguments.paths.results, + &evidence.policy, + &PipelineConverter, + ) { + Ok(report) => report, + Err(error) => { + let _ = writeln!(stderr, "sqlquack verify: failed: {error}"); + return ExitCode::FAILURE; + } + }; + let rendered = if arguments.json { + serde_json::to_string_pretty(&report).unwrap_or_default() + "\n" + } else { + render(&report) + }; + let _ = stdout.write_all(rendered.as_bytes()); + let failed = count(&report, |verdict| matches!(verdict, Verdict::Failed { .. })); + let _ = writeln!( + stderr, + "sqlquack verify: {}, {} cases: {} passed, {} refused, {} pending, {failed} failed", + if failed == 0 { "done" } else { "FAILED" }, + report.cases.len(), + count(&report, |verdict| *verdict == Verdict::Passed), + count(&report, |verdict| matches!( + verdict, + Verdict::Refused { .. } + )), + count(&report, |verdict| *verdict == Verdict::Pending), + ); + if failed == 0 { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +fn count(report: &VerifyReport, predicate: impl Fn(&Verdict) -> bool) -> usize { + report + .cases + .iter() + .filter(|case| predicate(&case.verdict)) + .count() +} + +fn render(report: &VerifyReport) -> String { + let mut lines: Vec = report + .cases + .iter() + .map(|case| { + let detail = match &case.verdict { + Verdict::Pending => "pending: no recorded result yet".to_owned(), + Verdict::Passed => "passed".to_owned(), + Verdict::Refused { reasons } => { + format!( + "refused: {} reasons, first: {}", + reasons.len(), + reasons.first().map_or("", String::as_str) + ) + } + Verdict::Failed { reason } => format!("FAILED: {reason}"), + }; + format!("{} {detail}", case.reference) + }) + .collect(); + lines.extend( + report + .unreferenced_results + .iter() + .map(|hash| format!("note: result {hash} is no longer named by any case")), + ); + lines.join("\n") + "\n" +} diff --git a/crates/sqlquack/src/cli/main/run_cli.rs b/crates/sqlquack/src/cli/main/run_cli.rs index 3c5890a58..fd8039708 100644 --- a/crates/sqlquack/src/cli/main/run_cli.rs +++ b/crates/sqlquack/src/cli/main/run_cli.rs @@ -4,7 +4,10 @@ use std::process::ExitCode; use clap::Parser; +use crate::cli::_helpers::audit_command::audit_command; use crate::cli::_helpers::census_command::census_command; +use crate::cli::_helpers::pending_command::pending_command; +use crate::cli::_helpers::verify_command::verify_command; use crate::cli::models::{Cli, Command}; /// Runs `sqlquack` with the process arguments. @@ -24,5 +27,8 @@ pub fn run_cli() -> ExitCode { let mut stderr = std::io::stderr().lock(); match &cli.command { Command::Census(arguments) => census_command(arguments, &mut stdout, &mut stderr), + Command::Pending(arguments) => pending_command(arguments, &mut stderr), + Command::Verify(arguments) => verify_command(arguments, &mut stdout, &mut stderr), + Command::Audit(arguments) => audit_command(arguments, &mut stdout, &mut stderr), } } diff --git a/crates/sqlquack/src/cli/models.rs b/crates/sqlquack/src/cli/models.rs index 241e915ec..e0d3c3ff4 100644 --- a/crates/sqlquack/src/cli/models.rs +++ b/crates/sqlquack/src/cli/models.rs @@ -4,6 +4,11 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; +use sqlquack_evidence::constants::{ + AUDIT_PENDING_DIR, AUDIT_RESULTS_DIR, CASES_DIR, PENDING_DIR, RESULTS_DIR, +}; +use sqlquack_policy::policy::constants::POLICY_FILE_NAME; + use crate::census::constants::DEFAULT_TEXT_LIMIT; /// Snowflake-to-DuckDB SQL conversion that refuses to guess. @@ -20,6 +25,74 @@ pub struct Cli { pub enum Command { /// Count the constructs SQL files use and estimate how many files convert as rules are added. Census(CensusArguments), + /// Write a pending case file for every case without a recorded result. + Pending(PendingArguments), + /// Replay every recorded case on DuckDB and compare with the recorded result. + Verify(VerifyArguments), + /// Sample recorded cases for re-recording, or compare re-recorded results with stored ones. + Audit(AuditArguments), +} + +/// Where evidence lives. +#[derive(Debug, Args)] +pub struct EvidencePaths { + /// The policy file. + #[arg(long, default_value = POLICY_FILE_NAME)] + pub policy: PathBuf, + /// The authored case files. + #[arg(long, default_value = CASES_DIR)] + pub cases: PathBuf, + /// The recorded results. + #[arg(long, default_value = RESULTS_DIR)] + pub results: PathBuf, +} + +/// Arguments of `sqlquack pending`. +#[derive(Debug, Args)] +pub struct PendingArguments { + /// Where evidence lives. + #[command(flatten)] + pub paths: EvidencePaths, + /// Where pending case files are written. + #[arg(long, default_value = PENDING_DIR)] + pub out: PathBuf, +} + +/// Arguments of `sqlquack verify`. +#[derive(Debug, Args)] +pub struct VerifyArguments { + /// Where evidence lives. + #[command(flatten)] + pub paths: EvidencePaths, + /// Print the report as JSON on standard output. + #[arg(long)] + pub json: bool, +} + +/// Arguments of `sqlquack audit`. +#[derive(Debug, Args)] +pub struct AuditArguments { + /// Where evidence lives. + #[command(flatten)] + pub paths: EvidencePaths, + /// The share of recorded cases to re-record, such as `10%`. + #[arg(long, conflicts_with = "compare", required_unless_present = "compare")] + pub sample: Option, + /// The sampling seed; the current time when absent, and always printed. + #[arg(long)] + pub seed: Option, + /// Where the sample's pending case files are written. + #[arg(long, default_value = AUDIT_PENDING_DIR)] + pub out: PathBuf, + /// Compare the re-recorded results with the stored ones. + #[arg(long)] + pub compare: bool, + /// Where the re-recorded results are. + #[arg(long, default_value = AUDIT_RESULTS_DIR)] + pub audit_results: PathBuf, + /// Print the comparison as JSON on standard output. + #[arg(long)] + pub json: bool, } /// Arguments of `sqlquack census`. diff --git a/crates/sqlquack/tests/evidence.rs b/crates/sqlquack/tests/evidence.rs new file mode 100644 index 000000000..e35f47bfe --- /dev/null +++ b/crates/sqlquack/tests/evidence.rs @@ -0,0 +1,6 @@ +#[path = "evidence/helpers.rs"] +mod helpers; +#[path = "evidence/test_evidence_commands.rs"] +mod test_evidence_commands; +#[path = "evidence/test_types.rs"] +mod test_types; diff --git a/crates/sqlquack/tests/evidence/helpers.rs b/crates/sqlquack/tests/evidence/helpers.rs new file mode 100644 index 000000000..863e5cf5b --- /dev/null +++ b/crates/sqlquack/tests/evidence/helpers.rs @@ -0,0 +1,43 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::test_types::CommandOutcome; + +fn repository_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn copy_tree(from: &Path, to: &Path) { + std::fs::create_dir_all(to).expect("directory created"); + for entry in std::fs::read_dir(from).expect("directory listed").flatten() { + let target = to.join(entry.file_name()); + if entry.path().is_dir() { + copy_tree(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), &target).expect("file copied"); + } + } +} + +/// Runs a command in a copy of the repository's policy and cases, with no recorded results. +pub(crate) fn run_on_repository_cases(arguments: &[&str], mention: &str) -> CommandOutcome { + let directory = tempfile::tempdir().expect("temporary directory"); + let root = repository_root(); + std::fs::copy( + root.join("sqlquack.toml"), + directory.path().join("sqlquack.toml"), + ) + .expect("policy copied"); + copy_tree(&root.join("cases"), &directory.path().join("cases")); + let output = Command::new(env!("CARGO_BIN_EXE_sqlquack")) + .args(arguments) + .current_dir(directory.path()) + .output() + .expect("sqlquack runs"); + let pending = directory.path().join("evidence/pending"); + CommandOutcome { + exit_code: output.status.code(), + pending_files: std::fs::read_dir(pending).map_or(0, Iterator::count), + stderr_mentions: String::from_utf8_lossy(&output.stderr).contains(mention), + } +} diff --git a/crates/sqlquack/tests/evidence/test_evidence_commands.rs b/crates/sqlquack/tests/evidence/test_evidence_commands.rs new file mode 100644 index 000000000..f0be6da62 --- /dev/null +++ b/crates/sqlquack/tests/evidence/test_evidence_commands.rs @@ -0,0 +1,46 @@ +use crate::helpers::run_on_repository_cases; +use crate::test_types::{CommandOutcome, CommandTestCase}; + +#[test] +fn given_repository_policy_and_cases_when_running_evidence_commands_then_they_load_and_report() { + let test_cases = [ + CommandTestCase { + description: "pending writes one file per seed case", + arguments: &["pending"], + mention: "9 pending case files", + expected_outcome: CommandOutcome { + exit_code: Some(0), + pending_files: 9, + stderr_mentions: true, + }, + }, + CommandTestCase { + description: "verify validates the policy and cases and reports every case pending", + arguments: &["verify"], + mention: "9 pending, 0 failed", + expected_outcome: CommandOutcome { + exit_code: Some(0), + pending_files: 0, + stderr_mentions: true, + }, + }, + CommandTestCase { + description: "an audit sample with nothing recorded writes nothing", + arguments: &["audit", "--sample", "50%", "--seed", "7"], + mention: "sampled 0 recorded cases", + expected_outcome: CommandOutcome { + exit_code: Some(0), + pending_files: 0, + stderr_mentions: true, + }, + }, + ]; + for test_case in test_cases { + assert_eq!( + run_on_repository_cases(test_case.arguments, test_case.mention), + test_case.expected_outcome, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack/tests/evidence/test_types.rs b/crates/sqlquack/tests/evidence/test_types.rs new file mode 100644 index 000000000..b01035728 --- /dev/null +++ b/crates/sqlquack/tests/evidence/test_types.rs @@ -0,0 +1,13 @@ +pub(crate) struct CommandTestCase { + pub(crate) description: &'static str, + pub(crate) arguments: &'static [&'static str], + pub(crate) mention: &'static str, + pub(crate) expected_outcome: CommandOutcome, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct CommandOutcome { + pub(crate) exit_code: Option, + pub(crate) pending_files: usize, + pub(crate) stderr_mentions: bool, +} diff --git a/evidence/results/README.md b/evidence/results/README.md new file mode 100644 index 000000000..ac712169f --- /dev/null +++ b/evidence/results/README.md @@ -0,0 +1,7 @@ +# Recorded results + +Snowflake results recorded by `sqlquack-record`, one `.json` metadata file and, for +row outcomes, one `.arrow` Arrow IPC file per case. + +These files are append-only. CI rejects any change to or deletion of an existing file. A changed +case has a new hash and is recorded as a new result. From 00079e0051278c6d64548c1869438de152b867f3 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:24:41 +0100 Subject: [PATCH 03/11] refactor: evidence and recorder layout --- .../src/values/_helpers/decode.rs | 249 +++--- .../src/audit/_helpers/mod.rs | 1 + .../src/audit/_helpers/outcomes.rs | 83 ++ .../src/audit/main/compare_audit.rs | 93 +-- crates/sqlquack-evidence/src/audit/mod.rs | 3 +- .../src/cases/_helpers/files.rs | 5 +- .../case_hash.rs => _helpers/hashing.rs} | 5 +- .../src/cases/_helpers/loading.rs | 2 +- .../src/cases/_helpers/mod.rs | 1 + .../sqlquack-evidence/src/cases/main/mod.rs | 1 - .../src/cases/tests/helpers.rs | 10 +- crates/sqlquack-evidence/src/constants.rs | 9 + .../src/pending/main/write_pending.rs | 17 +- .../src/replay/main/replay_on_duckdb.rs | 2 +- .../src/results/_helpers/mod.rs | 1 + .../src/results/_helpers/reading.rs | 2 +- .../snowflake_type.rs => _helpers/types.rs} | 5 +- .../sqlquack-evidence/src/results/main/mod.rs | 2 +- .../main/recorded_hashes.rs} | 5 +- .../sqlquack-evidence/src/results/models.rs | 6 +- .../src/verify/_helpers/mod.rs | 1 - .../src/verify/main/verify_cases.rs | 4 +- .../src/verify/tests/helpers.rs | 5 +- .../src/policy/_helpers/mod.rs | 2 + .../parse_policy.rs => _helpers/parsing.rs} | 5 +- .../type_patterns.rs} | 5 +- .../src/policy/_helpers/validation.rs | 2 +- .../src/policy/main/load_policy.rs | 2 +- .../src/policy/main/matching_type_equality.rs | 40 +- crates/sqlquack-policy/src/policy/main/mod.rs | 2 - .../src/policy/tests/helpers.rs | 2 +- fensu.toml | 7 + pyproject.toml | 21 +- recorder/pyproject.toml | 20 + recorder/src/sqlquack_record/__init__.py | 1 + recorder/src/sqlquack_record/cli/__init__.py | 1 + .../src/sqlquack_record/cli/main/__init__.py | 1 + .../sqlquack_record/cli/main/run_recorder.py | 77 ++ recorder/src/sqlquack_record/constants.py | 23 + recorder/src/sqlquack_record/errors.py | 7 + recorder/src/sqlquack_record/models.py | 70 ++ .../src/sqlquack_record/pending/__init__.py | 1 + .../sqlquack_record/pending/main/__init__.py | 1 + .../pending/main/load_pending.py | 66 ++ .../src/sqlquack_record/recording/__init__.py | 1 + .../recording/_helpers/__init__.py | 1 + .../recording/_helpers/batches.py | 24 + .../recording/main/__init__.py | 1 + .../recording/main/record_pending.py | 59 ++ .../src/sqlquack_record/results/__init__.py | 1 + .../results/_helpers/__init__.py | 1 + .../sqlquack_record/results/_helpers/files.py | 18 + .../sqlquack_record/results/main/__init__.py | 1 + .../results/main/write_result.py | 70 ++ .../src/sqlquack_record/snowflake/__init__.py | 1 + .../snowflake/_helpers/__init__.py | 1 + .../snowflake/_helpers/session.py | 114 +++ .../snowflake/main/__init__.py | 1 + .../snowflake/main/open_snowflake_sessions.py | 40 + .../src/sqlquack_record/snowflake/models.py | 14 + recorder/src/sqlquack_record/types.py | 35 + recorder/tests/__init__.py | 1 + recorder/tests/recording/__init__.py | 1 + recorder/tests/recording/_test_types.py | 19 + recorder/tests/recording/helpers.py | 140 ++++ .../tests/recording/test_record_pending.py | 85 ++ scripts/__init__.py | 1 + scripts/check_evidence_append_only.py | 14 + scripts/evidence_guard/__init__.py | 1 + scripts/evidence_guard/_helpers/__init__.py | 1 + scripts/evidence_guard/_helpers/git.py | 33 + scripts/evidence_guard/_helpers/metadata.py | 53 ++ scripts/evidence_guard/constants.py | 27 + scripts/evidence_guard/main/__init__.py | 1 + scripts/evidence_guard/main/check.py | 43 + scripts/evidence_guard/models.py | 13 + tests/__init__.py | 1 + tests/scripts/__init__.py | 1 + tests/scripts/evidence_guard/__init__.py | 1 + tests/scripts/evidence_guard/_test_types.py | 13 + tests/scripts/evidence_guard/helpers.py | 67 ++ tests/scripts/evidence_guard/test_check.py | 61 ++ uv.lock | 751 ++++++++++++++++++ 83 files changed, 2274 insertions(+), 305 deletions(-) create mode 100644 crates/sqlquack-evidence/src/audit/_helpers/mod.rs create mode 100644 crates/sqlquack-evidence/src/audit/_helpers/outcomes.rs rename crates/sqlquack-evidence/src/cases/{main/case_hash.rs => _helpers/hashing.rs} (56%) rename crates/sqlquack-evidence/src/results/{main/snowflake_type.rs => _helpers/types.rs} (74%) rename crates/sqlquack-evidence/src/{verify/_helpers/listing.rs => results/main/recorded_hashes.rs} (70%) rename crates/sqlquack-policy/src/policy/{main/parse_policy.rs => _helpers/parsing.rs} (65%) rename crates/sqlquack-policy/src/policy/{main/parse_type_pattern.rs => _helpers/type_patterns.rs} (87%) create mode 100644 recorder/pyproject.toml create mode 100644 recorder/src/sqlquack_record/__init__.py create mode 100644 recorder/src/sqlquack_record/cli/__init__.py create mode 100644 recorder/src/sqlquack_record/cli/main/__init__.py create mode 100644 recorder/src/sqlquack_record/cli/main/run_recorder.py create mode 100644 recorder/src/sqlquack_record/constants.py create mode 100644 recorder/src/sqlquack_record/errors.py create mode 100644 recorder/src/sqlquack_record/models.py create mode 100644 recorder/src/sqlquack_record/pending/__init__.py create mode 100644 recorder/src/sqlquack_record/pending/main/__init__.py create mode 100644 recorder/src/sqlquack_record/pending/main/load_pending.py create mode 100644 recorder/src/sqlquack_record/recording/__init__.py create mode 100644 recorder/src/sqlquack_record/recording/_helpers/__init__.py create mode 100644 recorder/src/sqlquack_record/recording/_helpers/batches.py create mode 100644 recorder/src/sqlquack_record/recording/main/__init__.py create mode 100644 recorder/src/sqlquack_record/recording/main/record_pending.py create mode 100644 recorder/src/sqlquack_record/results/__init__.py create mode 100644 recorder/src/sqlquack_record/results/_helpers/__init__.py create mode 100644 recorder/src/sqlquack_record/results/_helpers/files.py create mode 100644 recorder/src/sqlquack_record/results/main/__init__.py create mode 100644 recorder/src/sqlquack_record/results/main/write_result.py create mode 100644 recorder/src/sqlquack_record/snowflake/__init__.py create mode 100644 recorder/src/sqlquack_record/snowflake/_helpers/__init__.py create mode 100644 recorder/src/sqlquack_record/snowflake/_helpers/session.py create mode 100644 recorder/src/sqlquack_record/snowflake/main/__init__.py create mode 100644 recorder/src/sqlquack_record/snowflake/main/open_snowflake_sessions.py create mode 100644 recorder/src/sqlquack_record/snowflake/models.py create mode 100644 recorder/src/sqlquack_record/types.py create mode 100644 recorder/tests/__init__.py create mode 100644 recorder/tests/recording/__init__.py create mode 100644 recorder/tests/recording/_test_types.py create mode 100644 recorder/tests/recording/helpers.py create mode 100644 recorder/tests/recording/test_record_pending.py create mode 100644 scripts/__init__.py create mode 100644 scripts/check_evidence_append_only.py create mode 100644 scripts/evidence_guard/__init__.py create mode 100644 scripts/evidence_guard/_helpers/__init__.py create mode 100644 scripts/evidence_guard/_helpers/git.py create mode 100644 scripts/evidence_guard/_helpers/metadata.py create mode 100644 scripts/evidence_guard/constants.py create mode 100644 scripts/evidence_guard/main/__init__.py create mode 100644 scripts/evidence_guard/main/check.py create mode 100644 scripts/evidence_guard/models.py create mode 100644 tests/__init__.py create mode 100644 tests/scripts/__init__.py create mode 100644 tests/scripts/evidence_guard/__init__.py create mode 100644 tests/scripts/evidence_guard/_test_types.py create mode 100644 tests/scripts/evidence_guard/helpers.py create mode 100644 tests/scripts/evidence_guard/test_check.py diff --git a/crates/sqlquack-compare/src/values/_helpers/decode.rs b/crates/sqlquack-compare/src/values/_helpers/decode.rs index f40142da6..314d7af0d 100644 --- a/crates/sqlquack-compare/src/values/_helpers/decode.rs +++ b/crates/sqlquack-compare/src/values/_helpers/decode.rs @@ -2,8 +2,8 @@ use arrow::array::{Array, AsArray}; use arrow::datatypes::{ - DataType, Date32Type, Date64Type, Decimal128Type, Float32Type, Float64Type, Int8Type, - Int16Type, Int32Type, Int64Type, Time32MillisecondType, Time32SecondType, + ArrowPrimitiveType, DataType, Date32Type, Date64Type, Decimal128Type, Float32Type, Float64Type, + Int8Type, Int16Type, Int32Type, Int64Type, Time32MillisecondType, Time32SecondType, Time64MicrosecondType, Time64NanosecondType, TimeUnit, TimestampMicrosecondType, TimestampMillisecondType, TimestampNanosecondType, TimestampSecondType, UInt8Type, UInt16Type, UInt32Type, UInt64Type, @@ -12,78 +12,35 @@ use arrow::datatypes::{ use crate::values::models::CanonicalValue; const MILLIS_PER_DAY: i64 = 86_400_000; +const NANOS_PER_SECOND: i64 = 1_000_000_000; +const NANOS_PER_MILLI: i64 = 1_000_000; +const NANOS_PER_MICRO: i64 = 1_000; pub(crate) fn column_values(array: &dyn Array) -> Result, String> { - let values: Vec> = match array.data_type() { - DataType::Null => vec![None; array.len()], - DataType::Boolean => array - .as_boolean() - .iter() - .map(|value| value.map(CanonicalValue::Boolean)) - .collect(), - DataType::Int8 => integers( - array - .as_primitive::() - .iter() - .map(|value| value.map(i128::from)), - ), - DataType::Int16 => integers( - array - .as_primitive::() - .iter() - .map(|value| value.map(i128::from)), - ), - DataType::Int32 => integers( - array - .as_primitive::() - .iter() - .map(|value| value.map(i128::from)), - ), - DataType::Int64 => integers( - array - .as_primitive::() - .iter() - .map(|value| value.map(i128::from)), - ), - DataType::UInt8 => integers( - array - .as_primitive::() - .iter() - .map(|value| value.map(i128::from)), - ), - DataType::UInt16 => integers( - array - .as_primitive::() - .iter() - .map(|value| value.map(i128::from)), - ), - DataType::UInt32 => integers( - array - .as_primitive::() - .iter() - .map(|value| value.map(i128::from)), - ), - DataType::UInt64 => integers( - array - .as_primitive::() - .iter() - .map(|value| value.map(i128::from)), - ), - DataType::Decimal128(_, scale) => array - .as_primitive::() - .iter() - .map(|value| value.map(|unscaled| number(unscaled, *scale))) - .collect(), - DataType::Float32 => array - .as_primitive::() - .iter() - .map(|value| value.map(float32)) - .collect(), - DataType::Float64 => array - .as_primitive::() - .iter() - .map(|value| value.map(float64)) - .collect(), + let zone: Option = match array.data_type() { + DataType::Timestamp(_, zone) => zone.as_ref().map(ToString::to_string), + _ => None, + }; + let instant = |ticks: i64, factor: i64| CanonicalValue::Timestamp { + nanos: i128::from(ticks) * i128::from(factor), + zone: zone.clone(), + }; + Ok(match array.data_type() { + DataType::Null => vec![CanonicalValue::Null; array.len()], + DataType::Boolean => booleans(array), + DataType::Int8 => primitive::(array, |value| number(i128::from(value), 0)), + DataType::Int16 => primitive::(array, |value| number(i128::from(value), 0)), + DataType::Int32 => primitive::(array, |value| number(i128::from(value), 0)), + DataType::Int64 => primitive::(array, |value| number(i128::from(value), 0)), + DataType::UInt8 => primitive::(array, |value| number(i128::from(value), 0)), + DataType::UInt16 => primitive::(array, |value| number(i128::from(value), 0)), + DataType::UInt32 => primitive::(array, |value| number(i128::from(value), 0)), + DataType::UInt64 => primitive::(array, |value| number(i128::from(value), 0)), + DataType::Decimal128(_, scale) => { + primitive::(array, |value| number(value, *scale)) + } + DataType::Float32 => primitive::(array, float32), + DataType::Float64 => primitive::(array, float64), DataType::Utf8 => texts(array.as_string::().iter()), DataType::LargeUtf8 => texts(array.as_string::().iter()), DataType::Utf8View => texts(array.as_string_view().iter()), @@ -91,86 +48,46 @@ pub(crate) fn column_values(array: &dyn Array) -> Result, St DataType::LargeBinary => binaries(array.as_binary::().iter()), DataType::BinaryView => binaries(array.as_binary_view().iter()), DataType::FixedSizeBinary(_) => binaries(array.as_fixed_size_binary().iter()), - DataType::Date32 => array - .as_primitive::() - .iter() - .map(|value| value.map(|days| CanonicalValue::Date(i64::from(days)))) - .collect(), - DataType::Date64 => array - .as_primitive::() - .iter() - .map(|value| { - value.map(|millis| CanonicalValue::Date(millis.div_euclid(MILLIS_PER_DAY))) + DataType::Date32 => { + primitive::(array, |days| CanonicalValue::Date(i64::from(days))) + } + DataType::Date64 => primitive::(array, |millis| { + CanonicalValue::Date(millis.div_euclid(MILLIS_PER_DAY)) + }), + DataType::Time32(TimeUnit::Second) => primitive::(array, |ticks| { + CanonicalValue::Time(i64::from(ticks) * NANOS_PER_SECOND) + }), + DataType::Time32(TimeUnit::Millisecond) => { + primitive::(array, |ticks| { + CanonicalValue::Time(i64::from(ticks) * NANOS_PER_MILLI) }) - .collect(), - DataType::Time32(TimeUnit::Second) => times( - array - .as_primitive::() - .iter() - .map(|value| value.map(i64::from)), - 1_000_000_000, - ), - DataType::Time32(TimeUnit::Millisecond) => times( - array - .as_primitive::() - .iter() - .map(|value| value.map(i64::from)), - 1_000_000, - ), + } DataType::Time64(TimeUnit::Microsecond) => { - times(array.as_primitive::().iter(), 1_000) + primitive::(array, |ticks| { + CanonicalValue::Time(ticks * NANOS_PER_MICRO) + }) } DataType::Time64(TimeUnit::Nanosecond) => { - times(array.as_primitive::().iter(), 1) + primitive::(array, CanonicalValue::Time) } - DataType::Timestamp(unit, zone) => { - let zone = zone.as_ref().map(ToString::to_string); - let (raw, factor): (Vec>, i128) = match unit { - TimeUnit::Second => ( - array.as_primitive::().iter().collect(), - 1_000_000_000, - ), - TimeUnit::Millisecond => ( - array - .as_primitive::() - .iter() - .collect(), - 1_000_000, - ), - TimeUnit::Microsecond => ( - array - .as_primitive::() - .iter() - .collect(), - 1_000, - ), - TimeUnit::Nanosecond => ( - array - .as_primitive::() - .iter() - .collect(), - 1, - ), - }; - raw.into_iter() - .map(|value| { - value.map(|ticks| CanonicalValue::Timestamp { - nanos: i128::from(ticks) * factor, - zone: zone.clone(), - }) - }) - .collect() + DataType::Timestamp(TimeUnit::Second, _) => { + primitive::(array, |ticks| instant(ticks, NANOS_PER_SECOND)) + } + DataType::Timestamp(TimeUnit::Millisecond, _) => { + primitive::(array, |ticks| instant(ticks, NANOS_PER_MILLI)) + } + DataType::Timestamp(TimeUnit::Microsecond, _) => { + primitive::(array, |ticks| instant(ticks, NANOS_PER_MICRO)) + } + DataType::Timestamp(TimeUnit::Nanosecond, _) => { + primitive::(array, |ticks| instant(ticks, 1)) } other => { return Err(format!( "values of Arrow type {other} have no canonical form" )); } - }; - Ok(values - .into_iter() - .map(|value| value.unwrap_or(CanonicalValue::Null)) - .collect()) + }) } pub(crate) fn number(unscaled: i128, scale: i8) -> CanonicalValue { @@ -192,10 +109,24 @@ pub(crate) fn number(unscaled: i128, scale: i8) -> CanonicalValue { CanonicalValue::Number { unscaled, scale } } -fn integers(values: impl Iterator>) -> Vec> { +fn primitive( + array: &dyn Array, + convert: impl Fn(T::Native) -> CanonicalValue, +) -> Vec { + let typed = array.as_primitive::(); + let mut values: Vec = Vec::with_capacity(typed.len()); + for value in typed.iter() { + values.push(value.map_or(CanonicalValue::Null, &convert)); + } + values +} + +fn booleans(array: &dyn Array) -> Vec { + let mut values: Vec = Vec::with_capacity(array.len()); + for value in array.as_boolean().iter() { + values.push(value.map_or(CanonicalValue::Null, CanonicalValue::Boolean)); + } values - .map(|value| value.map(|unscaled| number(unscaled, 0))) - .collect() } fn float32(value: f32) -> CanonicalValue { @@ -214,20 +145,22 @@ fn float64(value: f64) -> CanonicalValue { }) } -fn texts<'a>(values: impl Iterator>) -> Vec> { - values - .map(|value| value.map(|text| CanonicalValue::Text(text.to_owned()))) - .collect() -} - -fn binaries<'a>(values: impl Iterator>) -> Vec> { - values - .map(|value| value.map(|bytes| CanonicalValue::Binary(bytes.to_vec()))) - .collect() +fn texts<'a>(values: impl Iterator>) -> Vec { + let mut canonical: Vec = Vec::new(); + for value in values { + canonical.push(value.map_or(CanonicalValue::Null, |text| { + CanonicalValue::Text(text.to_owned()) + })); + } + canonical } -fn times(values: impl Iterator>, factor: i64) -> Vec> { - values - .map(|value| value.map(|ticks| CanonicalValue::Time(ticks * factor))) - .collect() +fn binaries<'a>(values: impl Iterator>) -> Vec { + let mut canonical: Vec = Vec::new(); + for value in values { + canonical.push(value.map_or(CanonicalValue::Null, |bytes| { + CanonicalValue::Binary(bytes.to_vec()) + })); + } + canonical } diff --git a/crates/sqlquack-evidence/src/audit/_helpers/mod.rs b/crates/sqlquack-evidence/src/audit/_helpers/mod.rs new file mode 100644 index 000000000..0401bc079 --- /dev/null +++ b/crates/sqlquack-evidence/src/audit/_helpers/mod.rs @@ -0,0 +1 @@ +pub(crate) mod outcomes; diff --git a/crates/sqlquack-evidence/src/audit/_helpers/outcomes.rs b/crates/sqlquack-evidence/src/audit/_helpers/outcomes.rs new file mode 100644 index 000000000..0fbf55baa --- /dev/null +++ b/crates/sqlquack-evidence/src/audit/_helpers/outcomes.rs @@ -0,0 +1,83 @@ +//! Comparing one fresh recording with its stored result. + +use std::path::Path; + +use sqlquack_compare::comparison::main::compare_recordings::compare_recordings; +use sqlquack_compare::comparison::models::RowOrder; + +use crate::audit::models::{AuditEntry, AuditOutcome}; +use crate::cases::models::LoadedCase; +use crate::errors::EvidenceError; +use crate::results::main::load_result::load_result; +use crate::results::models::StoredResult; + +pub(crate) fn audit_entry( + cases: &[LoadedCase], + stored: &Path, + fresh: &Path, + hash: String, +) -> AuditEntry { + let case: Option<&LoadedCase> = cases.iter().find(|case| case.hash == hash); + let order = case.map_or(RowOrder::Ordered, |case| case.order); + let stored_result = load_result(stored, &hash); + let fresh_result = load_result(fresh, &hash); + AuditEntry { + reference: case.map(|case| case.reference.clone()), + stored_warehouse_version: warehouse_version(&stored_result), + fresh_warehouse_version: warehouse_version(&fresh_result), + outcome: outcome(stored_result, fresh_result, order), + hash, + } +} + +fn warehouse_version(result: &Result, EvidenceError>) -> Option { + match result { + Ok(Some(stored)) => Some(stored.metadata.warehouse_version.clone()), + Ok(None) | Err(_) => None, + } +} + +fn outcome( + stored: Result, EvidenceError>, + fresh: Result, EvidenceError>, + order: RowOrder, +) -> AuditOutcome { + let (stored, fresh) = match (stored, fresh) { + (Ok(Some(stored)), Ok(Some(fresh))) => (stored, fresh), + (Err(error), _) | (_, Err(error)) => { + return AuditOutcome::Invalid { + detail: error.to_string(), + }; + } + (Ok(None), _) | (_, Ok(None)) => { + return AuditOutcome::Invalid { + detail: "no stored result for this hash".to_owned(), + }; + } + }; + match (&stored.rows, &fresh.rows) { + (Some(stored_rows), Some(fresh_rows)) => { + match compare_recordings(stored_rows, fresh_rows, order) { + Ok(()) => AuditOutcome::Matched, + Err(mismatch) => AuditOutcome::Differed { + detail: mismatch.to_string(), + }, + } + } + (None, None) if error_key(&stored) == error_key(&fresh) => AuditOutcome::Matched, + _ => AuditOutcome::Differed { + detail: format!( + "stored outcome {:?} {:?}, fresh outcome {:?} {:?}", + stored.metadata.outcome, + error_key(&stored), + fresh.metadata.outcome, + error_key(&fresh) + ), + }, + } +} + +fn error_key(result: &StoredResult) -> Option<(Option, Option)> { + let error = result.metadata.error.as_ref()?; + Some((error.errno, error.sqlstate.clone())) +} diff --git a/crates/sqlquack-evidence/src/audit/main/compare_audit.rs b/crates/sqlquack-evidence/src/audit/main/compare_audit.rs index 6d9d27600..064f6abaf 100644 --- a/crates/sqlquack-evidence/src/audit/main/compare_audit.rs +++ b/crates/sqlquack-evidence/src/audit/main/compare_audit.rs @@ -2,15 +2,11 @@ use std::path::Path; -use sqlquack_compare::comparison::main::compare_recordings::compare_recordings; -use sqlquack_compare::comparison::models::RowOrder; - -use crate::audit::models::{AuditEntry, AuditOutcome}; +use crate::audit::_helpers::outcomes::audit_entry; +use crate::audit::models::AuditEntry; use crate::cases::models::LoadedCase; use crate::errors::EvidenceError; -use crate::results::main::load_result::load_result; -use crate::results::models::StoredResult; -use crate::verify::_helpers::listing::recorded_hashes; +use crate::results::main::recorded_hashes::recorded_hashes; /// One entry per fresh result in `fresh`, compared with the stored result in `stored`. pub fn compare_audit( @@ -18,84 +14,9 @@ pub fn compare_audit( stored: &Path, fresh: &Path, ) -> Result, EvidenceError> { - Ok(recorded_hashes(fresh)? - .into_iter() - .map(|hash| { - let case = cases.iter().find(|case| case.hash == hash); - let order = case.map_or(RowOrder::Ordered, |case| case.order); - let stored_result = load_result(stored, &hash); - let fresh_result = load_result(fresh, &hash); - AuditEntry { - reference: case.map(|case| case.reference.clone()), - stored_warehouse_version: warehouse_version(&stored_result), - fresh_warehouse_version: warehouse_version(&fresh_result), - outcome: outcome(stored_result, fresh_result, order), - hash, - } - }) - .collect()) -} - -fn warehouse_version(result: &Result, EvidenceError>) -> Option { - result - .as_ref() - .ok() - .and_then(Option::as_ref) - .map(|stored| stored.metadata.warehouse_version.clone()) -} - -fn outcome( - stored: Result, EvidenceError>, - fresh: Result, EvidenceError>, - order: RowOrder, -) -> AuditOutcome { - let (stored, fresh) = match (stored, fresh) { - (Ok(Some(stored)), Ok(Some(fresh))) => (stored, fresh), - (Ok(None), _) => { - return AuditOutcome::Invalid { - detail: "no stored result for this hash".to_owned(), - }; - } - (Err(error), _) | (_, Err(error)) => { - return AuditOutcome::Invalid { - detail: error.to_string(), - }; - } - (_, Ok(None)) => { - return AuditOutcome::Invalid { - detail: "the fresh result disappeared".to_owned(), - }; - } - }; - match (&stored.rows, &fresh.rows) { - (Some(stored_rows), Some(fresh_rows)) => { - match compare_recordings(stored_rows, fresh_rows, order) { - Ok(()) => AuditOutcome::Matched, - Err(mismatch) => AuditOutcome::Differed { - detail: mismatch.to_string(), - }, - } - } - (None, None) if same_error(&stored, &fresh) => AuditOutcome::Matched, - _ => AuditOutcome::Differed { - detail: format!( - "stored outcome {:?} ({:?}), fresh outcome {:?} ({:?})", - stored.metadata.outcome, - stored.metadata.error.as_ref().map(|error| &error.sqlstate), - fresh.metadata.outcome, - fresh.metadata.error.as_ref().map(|error| &error.sqlstate) - ), - }, + let mut entries: Vec = Vec::new(); + for hash in recorded_hashes(fresh)? { + entries.push(audit_entry(cases, stored, fresh, hash)); } -} - -fn same_error(stored: &StoredResult, fresh: &StoredResult) -> bool { - let key = |result: &StoredResult| { - result - .metadata - .error - .as_ref() - .map(|error| (error.errno, error.sqlstate.clone())) - }; - key(stored) == key(fresh) + Ok(entries) } diff --git a/crates/sqlquack-evidence/src/audit/mod.rs b/crates/sqlquack-evidence/src/audit/mod.rs index 9a9ffbcb5..5359000d1 100644 --- a/crates/sqlquack-evidence/src/audit/mod.rs +++ b/crates/sqlquack-evidence/src/audit/mod.rs @@ -1,4 +1,3 @@ -//! Audits: re-record a sample of cases and compare with the stored results. - +pub(crate) mod _helpers; pub mod main; pub mod models; diff --git a/crates/sqlquack-evidence/src/cases/_helpers/files.rs b/crates/sqlquack-evidence/src/cases/_helpers/files.rs index 2efdf06f2..225036fe6 100644 --- a/crates/sqlquack-evidence/src/cases/_helpers/files.rs +++ b/crates/sqlquack-evidence/src/cases/_helpers/files.rs @@ -5,6 +5,7 @@ use std::path::Path; use walkdir::WalkDir; use crate::cases::models::CaseFile; +use crate::constants::CASE_FILE_EXTENSION; use crate::errors::EvidenceError; pub(crate) fn read_case_files(directory: &Path) -> Result, EvidenceError> { @@ -14,7 +15,9 @@ pub(crate) fn read_case_files(directory: &Path) -> Result) -> String { +pub(crate) fn case_hash(content: &HashedContent<'_>) -> String { let canonical = serde_json::to_vec(content).unwrap_or_default(); Sha256::digest(&canonical) .iter() diff --git a/crates/sqlquack-evidence/src/cases/_helpers/loading.rs b/crates/sqlquack-evidence/src/cases/_helpers/loading.rs index caf100e1f..712c565d5 100644 --- a/crates/sqlquack-evidence/src/cases/_helpers/loading.rs +++ b/crates/sqlquack-evidence/src/cases/_helpers/loading.rs @@ -5,8 +5,8 @@ use std::collections::BTreeSet; use sqlquack_compare::comparison::models::RowOrder; use sqlquack_policy::policy::models::Policy; +use crate::cases::_helpers::hashing::case_hash; use crate::cases::_helpers::source_sql::source_sql; -use crate::cases::main::case_hash::case_hash; use crate::cases::models::{CaseDefinition, CaseFile, HashedContent, LoadedCase, QueryOrder}; use crate::constants::{EVIDENCE_FORMAT, ORDINAL_COLUMN, SOURCE_DIALECT, VALUE_COLUMN}; diff --git a/crates/sqlquack-evidence/src/cases/_helpers/mod.rs b/crates/sqlquack-evidence/src/cases/_helpers/mod.rs index e9515f227..3f177e1a2 100644 --- a/crates/sqlquack-evidence/src/cases/_helpers/mod.rs +++ b/crates/sqlquack-evidence/src/cases/_helpers/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod files; +pub(crate) mod hashing; pub(crate) mod loading; pub(crate) mod source_sql; diff --git a/crates/sqlquack-evidence/src/cases/main/mod.rs b/crates/sqlquack-evidence/src/cases/main/mod.rs index 63e0ec987..42f269123 100644 --- a/crates/sqlquack-evidence/src/cases/main/mod.rs +++ b/crates/sqlquack-evidence/src/cases/main/mod.rs @@ -1,2 +1 @@ -pub mod case_hash; pub mod load_cases; diff --git a/crates/sqlquack-evidence/src/cases/tests/helpers.rs b/crates/sqlquack-evidence/src/cases/tests/helpers.rs index 0fc91a47c..a90c91c63 100644 --- a/crates/sqlquack-evidence/src/cases/tests/helpers.rs +++ b/crates/sqlquack-evidence/src/cases/tests/helpers.rs @@ -1,6 +1,6 @@ use std::path::Path; -use sqlquack_policy::policy::main::parse_policy::parse_policy; +use sqlquack_policy::policy::main::load_policy::load_policy; use sqlquack_policy::policy::models::Policy; use crate::cases::main::load_cases::load_cases; @@ -62,8 +62,10 @@ order = "ordered" tags = [] "#; -fn policy() -> Policy { - parse_policy(POLICY).expect("the test policy is valid") +fn policy(directory: &Path) -> Policy { + let path = directory.join("sqlquack.toml"); + std::fs::write(&path, POLICY).expect("policy written"); + load_policy(&path).expect("the test policy is valid") } pub(crate) fn load_variant( @@ -78,7 +80,7 @@ pub(crate) fn load_variant( CASE_FILE.replacen(replace, with, 1), ) .expect("case file written"); - load_cases(&cases, &policy()).map_err(|error| error.to_string()) + load_cases(&cases, &policy(directory)).map_err(|error| error.to_string()) } pub(crate) fn hashes(replace: &str, with: &str) -> Vec { diff --git a/crates/sqlquack-evidence/src/constants.rs b/crates/sqlquack-evidence/src/constants.rs index 6e9a34405..89dda9109 100644 --- a/crates/sqlquack-evidence/src/constants.rs +++ b/crates/sqlquack-evidence/src/constants.rs @@ -29,3 +29,12 @@ pub const VALUE_COLUMN: &str = "value"; /// The relation that holds the case input rows. pub const INPUT_RELATION: &str = "input"; + +/// The extension of authored case files. +pub const CASE_FILE_EXTENSION: &str = "toml"; + +/// The extension of metadata and pending files. +pub const METADATA_EXTENSION: &str = "json"; + +/// The length of a case hash in hexadecimal digits. +pub const CASE_HASH_LENGTH: usize = 64; diff --git a/crates/sqlquack-evidence/src/pending/main/write_pending.rs b/crates/sqlquack-evidence/src/pending/main/write_pending.rs index 9f97f1c51..16c3376c4 100644 --- a/crates/sqlquack-evidence/src/pending/main/write_pending.rs +++ b/crates/sqlquack-evidence/src/pending/main/write_pending.rs @@ -4,7 +4,7 @@ use std::collections::BTreeSet; use std::path::Path; use crate::cases::models::LoadedCase; -use crate::constants::{EVIDENCE_FORMAT, SOURCE_DIALECT}; +use crate::constants::{CASE_HASH_LENGTH, EVIDENCE_FORMAT, METADATA_EXTENSION, SOURCE_DIALECT}; use crate::errors::EvidenceError; use crate::pending::models::{PendingCase, PendingSummary}; @@ -51,11 +51,16 @@ fn remove_stale(out: &Path, wanted: &BTreeSet) -> Result Result = arrow.collect(); Ok(ResultSet { schema, batches }) } diff --git a/crates/sqlquack-evidence/src/results/_helpers/mod.rs b/crates/sqlquack-evidence/src/results/_helpers/mod.rs index 864940733..a6fb24b74 100644 --- a/crates/sqlquack-evidence/src/results/_helpers/mod.rs +++ b/crates/sqlquack-evidence/src/results/_helpers/mod.rs @@ -1 +1,2 @@ pub(crate) mod reading; +pub(crate) mod types; diff --git a/crates/sqlquack-evidence/src/results/_helpers/reading.rs b/crates/sqlquack-evidence/src/results/_helpers/reading.rs index 0a6f45941..0a997bc7b 100644 --- a/crates/sqlquack-evidence/src/results/_helpers/reading.rs +++ b/crates/sqlquack-evidence/src/results/_helpers/reading.rs @@ -11,7 +11,7 @@ use sqlquack_compare::values::models::ResultSet; use crate::constants::EVIDENCE_FORMAT; use crate::errors::EvidenceError; -use crate::results::main::snowflake_type::snowflake_type; +use crate::results::_helpers::types::snowflake_type; use crate::results::models::ResultMetadata; pub(crate) fn sha256_hex(bytes: &[u8]) -> String { diff --git a/crates/sqlquack-evidence/src/results/main/snowflake_type.rs b/crates/sqlquack-evidence/src/results/_helpers/types.rs similarity index 74% rename from crates/sqlquack-evidence/src/results/main/snowflake_type.rs rename to crates/sqlquack-evidence/src/results/_helpers/types.rs index 0b3491dc8..69782a4a0 100644 --- a/crates/sqlquack-evidence/src/results/main/snowflake_type.rs +++ b/crates/sqlquack-evidence/src/results/_helpers/types.rs @@ -1,9 +1,8 @@ -//! `snowflake_type`: a warehouse column description as a Snowflake type name. +//! Warehouse column descriptions as Snowflake type names. use crate::results::models::WarehouseColumn; -/// `NUMBER(38,0)`, `VARCHAR(16777216)`, `TIMESTAMP_NTZ(9)` and so on. -pub fn snowflake_type(column: &WarehouseColumn) -> String { +pub(crate) fn snowflake_type(column: &WarehouseColumn) -> String { let precision = column.precision.unwrap_or_default(); let scale = column.scale.unwrap_or_default(); let size = column.internal_size.unwrap_or_default(); diff --git a/crates/sqlquack-evidence/src/results/main/mod.rs b/crates/sqlquack-evidence/src/results/main/mod.rs index 0845d623c..031a06530 100644 --- a/crates/sqlquack-evidence/src/results/main/mod.rs +++ b/crates/sqlquack-evidence/src/results/main/mod.rs @@ -1,2 +1,2 @@ pub mod load_result; -pub mod snowflake_type; +pub mod recorded_hashes; diff --git a/crates/sqlquack-evidence/src/verify/_helpers/listing.rs b/crates/sqlquack-evidence/src/results/main/recorded_hashes.rs similarity index 70% rename from crates/sqlquack-evidence/src/verify/_helpers/listing.rs rename to crates/sqlquack-evidence/src/results/main/recorded_hashes.rs index 85b0401d7..0e5146c30 100644 --- a/crates/sqlquack-evidence/src/verify/_helpers/listing.rs +++ b/crates/sqlquack-evidence/src/results/main/recorded_hashes.rs @@ -1,10 +1,11 @@ -//! The hashes recorded in a results directory. +//! `recorded_hashes`: the case hashes recorded in a results directory. use std::path::Path; use crate::errors::EvidenceError; -pub(crate) fn recorded_hashes(results: &Path) -> Result, EvidenceError> { +/// Every hash with a metadata file in `results`, sorted; none when the directory is absent. +pub fn recorded_hashes(results: &Path) -> Result, EvidenceError> { if !results.exists() { return Ok(Vec::new()); } diff --git a/crates/sqlquack-evidence/src/results/models.rs b/crates/sqlquack-evidence/src/results/models.rs index 39e7c5145..a98bb1ccd 100644 --- a/crates/sqlquack-evidence/src/results/models.rs +++ b/crates/sqlquack-evidence/src/results/models.rs @@ -37,7 +37,7 @@ pub struct ResultMetadata { /// SHA-256 of the Arrow IPC file, as lower-case hex. pub arrow_sha256: Option, /// The warehouse error, for error outcomes. - pub error: Option, + pub error: Option, } /// Whether the warehouse returned rows or an error. @@ -70,10 +70,10 @@ pub struct WarehouseColumn { pub nullable: Option, } -/// A warehouse error. +/// A warehouse error recorded as the case outcome. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -pub struct RecordedError { +pub struct RecordedFailure { /// The warehouse error number. pub errno: Option, /// The SQLSTATE. diff --git a/crates/sqlquack-evidence/src/verify/_helpers/mod.rs b/crates/sqlquack-evidence/src/verify/_helpers/mod.rs index f8a38ec56..fb9a585fc 100644 --- a/crates/sqlquack-evidence/src/verify/_helpers/mod.rs +++ b/crates/sqlquack-evidence/src/verify/_helpers/mod.rs @@ -1,2 +1 @@ pub(crate) mod judge; -pub(crate) mod listing; diff --git a/crates/sqlquack-evidence/src/verify/main/verify_cases.rs b/crates/sqlquack-evidence/src/verify/main/verify_cases.rs index 4ae896783..76a138ebd 100644 --- a/crates/sqlquack-evidence/src/verify/main/verify_cases.rs +++ b/crates/sqlquack-evidence/src/verify/main/verify_cases.rs @@ -7,8 +7,8 @@ use sqlquack_policy::policy::models::Policy; use crate::cases::models::LoadedCase; use crate::errors::EvidenceError; +use crate::results::main::recorded_hashes::recorded_hashes; use crate::verify::_helpers::judge::judge; -use crate::verify::_helpers::listing::recorded_hashes; use crate::verify::models::{CaseVerdict, VerifyReport}; use crate::verify::types::CaseConverter; @@ -20,7 +20,7 @@ pub fn verify_cases( converter: &dyn CaseConverter, ) -> Result { let referenced: BTreeSet<&str> = cases.iter().map(|case| case.hash.as_str()).collect(); - let unreferenced_results = recorded_hashes(results)? + let unreferenced_results: Vec = recorded_hashes(results)? .into_iter() .filter(|hash| !referenced.contains(hash.as_str())) .collect(); diff --git a/crates/sqlquack-evidence/src/verify/tests/helpers.rs b/crates/sqlquack-evidence/src/verify/tests/helpers.rs index c45e82d6a..97e1b6270 100644 --- a/crates/sqlquack-evidence/src/verify/tests/helpers.rs +++ b/crates/sqlquack-evidence/src/verify/tests/helpers.rs @@ -7,7 +7,7 @@ use arrow::array::{ArrayRef, Decimal128Array, RecordBatch}; use arrow::datatypes::DataType; use arrow::ipc::writer::FileWriter; use sqlquack_compare::values::models::ResultSet; -use sqlquack_policy::policy::main::parse_policy::parse_policy; +use sqlquack_policy::policy::main::load_policy::load_policy; use crate::cases::main::load_cases::load_cases; use crate::pending::main::write_pending::write_pending; @@ -209,7 +209,8 @@ pub(crate) fn round_trip(tampering: Tampering) -> RoundTrip { let results = root.join("results"); let pending_dir = root.join("pending"); std::fs::create_dir_all(&results).expect("results directory"); - let policy = parse_policy(POLICY).expect("the test policy is valid"); + std::fs::write(root.join("sqlquack.toml"), POLICY).expect("policy written"); + let policy = load_policy(&root.join("sqlquack.toml")).expect("the test policy is valid"); let cases = load_cases(&root.join("cases"), &policy).expect("cases load"); let before = write_pending(&cases, &results, &pending_dir, "v2.0.0").expect("pending written"); let mut pending: BTreeMap = BTreeMap::new(); diff --git a/crates/sqlquack-policy/src/policy/_helpers/mod.rs b/crates/sqlquack-policy/src/policy/_helpers/mod.rs index 08103424e..5d297457b 100644 --- a/crates/sqlquack-policy/src/policy/_helpers/mod.rs +++ b/crates/sqlquack-policy/src/policy/_helpers/mod.rs @@ -1 +1,3 @@ +pub(crate) mod parsing; +pub(crate) mod type_patterns; pub(crate) mod validation; diff --git a/crates/sqlquack-policy/src/policy/main/parse_policy.rs b/crates/sqlquack-policy/src/policy/_helpers/parsing.rs similarity index 65% rename from crates/sqlquack-policy/src/policy/main/parse_policy.rs rename to crates/sqlquack-policy/src/policy/_helpers/parsing.rs index 54e4dd646..ce7f474e4 100644 --- a/crates/sqlquack-policy/src/policy/main/parse_policy.rs +++ b/crates/sqlquack-policy/src/policy/_helpers/parsing.rs @@ -1,11 +1,10 @@ -//! `parse_policy`: policy text to a validated policy. +//! Policy text to a validated policy. use crate::policy::_helpers::validation::problems; use crate::policy::errors::PolicyError; use crate::policy::models::Policy; -/// Parses policy TOML, rejecting unknown keys, then checks every validation rule. -pub fn parse_policy(text: &str) -> Result { +pub(crate) fn parse_policy(text: &str) -> Result { let policy: Policy = toml::from_str(text).map_err(|error| PolicyError::Schema(error.to_string()))?; let found = problems(&policy); diff --git a/crates/sqlquack-policy/src/policy/main/parse_type_pattern.rs b/crates/sqlquack-policy/src/policy/_helpers/type_patterns.rs similarity index 87% rename from crates/sqlquack-policy/src/policy/main/parse_type_pattern.rs rename to crates/sqlquack-policy/src/policy/_helpers/type_patterns.rs index 5be7106bf..cb0264e75 100644 --- a/crates/sqlquack-policy/src/policy/main/parse_type_pattern.rs +++ b/crates/sqlquack-policy/src/policy/_helpers/type_patterns.rs @@ -1,9 +1,8 @@ -//! `parse_type_pattern`: `NUMBER(p,s)` and similar to a pattern. +//! Parsing type patterns such as `NUMBER(p,s)`. use crate::policy::models::{PatternArgument, TypePattern}; -/// Parses a type name with optional parameters; lower-case parameters are variables. -pub fn parse_type_pattern(text: &str) -> Result { +pub(crate) fn parse_type_pattern(text: &str) -> Result { let text = text.trim(); let (base, rest) = match text.split_once('(') { Some((base, rest)) => (base.trim(), Some(rest)), diff --git a/crates/sqlquack-policy/src/policy/_helpers/validation.rs b/crates/sqlquack-policy/src/policy/_helpers/validation.rs index 860ec6e86..cceb2af5b 100644 --- a/crates/sqlquack-policy/src/policy/_helpers/validation.rs +++ b/crates/sqlquack-policy/src/policy/_helpers/validation.rs @@ -2,8 +2,8 @@ use std::collections::BTreeSet; +use crate::policy::_helpers::type_patterns::parse_type_pattern; use crate::policy::constants::SCHEMA_VERSION; -use crate::policy::main::parse_type_pattern::parse_type_pattern; use crate::policy::models::{ Divergence, EdgeCaseRequirement, PatternArgument, Policy, SessionProfile, TypeEquality, TypePattern, diff --git a/crates/sqlquack-policy/src/policy/main/load_policy.rs b/crates/sqlquack-policy/src/policy/main/load_policy.rs index 0d2bb69d6..42a700a87 100644 --- a/crates/sqlquack-policy/src/policy/main/load_policy.rs +++ b/crates/sqlquack-policy/src/policy/main/load_policy.rs @@ -2,8 +2,8 @@ use std::path::Path; +use crate::policy::_helpers::parsing::parse_policy; use crate::policy::errors::PolicyError; -use crate::policy::main::parse_policy::parse_policy; use crate::policy::models::Policy; /// Reads `path` and validates it against the schema and every policy rule. diff --git a/crates/sqlquack-policy/src/policy/main/matching_type_equality.rs b/crates/sqlquack-policy/src/policy/main/matching_type_equality.rs index da9eedd81..53d4c2898 100644 --- a/crates/sqlquack-policy/src/policy/main/matching_type_equality.rs +++ b/crates/sqlquack-policy/src/policy/main/matching_type_equality.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use crate::policy::main::parse_type_pattern::parse_type_pattern; +use crate::policy::_helpers::type_patterns::parse_type_pattern; use crate::policy::models::{PatternArgument, TypeEquality, TypePattern}; /// The first declared equality whose patterns match `snowflake` and `duckdb` with one binding. @@ -11,8 +11,10 @@ pub fn matching_type_equality<'a>( snowflake: &str, duckdb: &str, ) -> Option<&'a TypeEquality> { - let snowflake = parse_type_pattern(snowflake).ok()?; - let duckdb = parse_type_pattern(duckdb).ok()?; + let (Ok(snowflake), Ok(duckdb)) = (parse_type_pattern(snowflake), parse_type_pattern(duckdb)) + else { + return None; + }; equalities .iter() .find(|equality| admits(equality, &snowflake, &duckdb)) @@ -25,26 +27,34 @@ fn admits(equality: &TypeEquality, snowflake: &TypePattern, duckdb: &TypePattern ) else { return false; }; - let mut bindings: BTreeMap = BTreeMap::new(); - bind(&snowflake_pattern, snowflake, &mut bindings) - && bind(&duckdb_pattern, duckdb, &mut bindings) + let Some(bindings) = bind(&snowflake_pattern, snowflake, BTreeMap::new()) else { + return false; + }; + bind(&duckdb_pattern, duckdb, bindings).is_some() } -fn bind(pattern: &TypePattern, actual: &TypePattern, bindings: &mut BTreeMap) -> bool { +fn bind( + pattern: &TypePattern, + actual: &TypePattern, + bindings: BTreeMap, +) -> Option> { if pattern.base != actual.base || pattern.arguments.len() != actual.arguments.len() { - return false; + return None; } - pattern - .arguments - .iter() - .zip(&actual.arguments) - .all(|(expected, found)| match (expected, found) { + let mut bound = bindings; + for (expected, found) in pattern.arguments.iter().zip(&actual.arguments) { + let consistent = match (expected, found) { (PatternArgument::Literal(expected), PatternArgument::Literal(found)) => { expected == found } (PatternArgument::Variable(name), PatternArgument::Literal(found)) => { - *bindings.entry(name.clone()).or_insert(*found) == *found + *bound.entry(name.clone()).or_insert(*found) == *found } _ => false, - }) + }; + if !consistent { + return None; + } + } + Some(bound) } diff --git a/crates/sqlquack-policy/src/policy/main/mod.rs b/crates/sqlquack-policy/src/policy/main/mod.rs index 4daa1bce7..05b575f04 100644 --- a/crates/sqlquack-policy/src/policy/main/mod.rs +++ b/crates/sqlquack-policy/src/policy/main/mod.rs @@ -1,4 +1,2 @@ pub mod load_policy; pub mod matching_type_equality; -pub mod parse_policy; -pub mod parse_type_pattern; diff --git a/crates/sqlquack-policy/src/policy/tests/helpers.rs b/crates/sqlquack-policy/src/policy/tests/helpers.rs index ab470a0fd..1be80b1c1 100644 --- a/crates/sqlquack-policy/src/policy/tests/helpers.rs +++ b/crates/sqlquack-policy/src/policy/tests/helpers.rs @@ -1,5 +1,5 @@ +use crate::policy::_helpers::parsing::parse_policy; use crate::policy::main::matching_type_equality::matching_type_equality; -use crate::policy::main::parse_policy::parse_policy; use crate::policy::models::Policy; pub(crate) const VALID_POLICY: &str = r#" diff --git a/fensu.toml b/fensu.toml index 6ead1ad07..2da081db8 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1,3 +1,10 @@ +[targets.python] +analyzer = "python" +roots = ["recorder/src/sqlquack_record"] +tests = ["recorder/tests", "tests"] +tooling = ["scripts"] +select = ["FF"] + [targets.rust] analyzer = "rust" roots = ["crates"] diff --git a/pyproject.toml b/pyproject.toml index 735c0aeb6..1bdc6c405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,39 @@ [project] name = "sqlquack-workspace" version = "0.0.0" -description = "Development tooling for the SQLQuack Rust workspace." +description = "Development tooling for SQLQuack: the recorder, repository checks and their tests." requires-python = ">=3.12" dependencies = [] [dependency-groups] dev = [ "fensu>=0.27.0", + "pytest>=8.4", "ruff>=0.12.0", + "sqlquack-record", "ty>=0.0.1a19", ] [tool.uv] package = false +[tool.uv.workspace] +members = ["recorder"] + +[tool.uv.sources] +sqlquack-record = { workspace = true } + +[tool.pytest.ini_options] +testpaths = ["recorder/tests", "tests"] +addopts = "--import-mode=importlib" +pythonpath = ["."] + [tool.ruff] line-length = 100 target-version = "py312" + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "PLR0913", "PLR0915", "UP"] + +[tool.ruff.lint.isort] +known-first-party = ["sqlquack_record", "scripts", "tests", "recorder"] diff --git a/recorder/pyproject.toml b/recorder/pyproject.toml new file mode 100644 index 000000000..a7c1d0f7d --- /dev/null +++ b/recorder/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "sqlquack-record" +version = "0.1.0" +description = "Records Snowflake results for SQLQuack's pending cases as Arrow IPC files." +requires-python = ">=3.12" +license = "Apache-2.0" +dependencies = [ + "pyarrow==24.0.0", + "snowflake-connector-python[secure-local-storage]==4.7.2", +] + +[project.scripts] +sqlquack-record = "sqlquack_record.cli.main.run_recorder:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sqlquack_record"] diff --git a/recorder/src/sqlquack_record/__init__.py b/recorder/src/sqlquack_record/__init__.py new file mode 100644 index 000000000..cdc4a3251 --- /dev/null +++ b/recorder/src/sqlquack_record/__init__.py @@ -0,0 +1 @@ +"""Records Snowflake results for SQLQuack's pending cases.""" diff --git a/recorder/src/sqlquack_record/cli/__init__.py b/recorder/src/sqlquack_record/cli/__init__.py new file mode 100644 index 000000000..519a9b3ee --- /dev/null +++ b/recorder/src/sqlquack_record/cli/__init__.py @@ -0,0 +1 @@ +"""cli package.""" diff --git a/recorder/src/sqlquack_record/cli/main/__init__.py b/recorder/src/sqlquack_record/cli/main/__init__.py new file mode 100644 index 000000000..dd3234f36 --- /dev/null +++ b/recorder/src/sqlquack_record/cli/main/__init__.py @@ -0,0 +1 @@ +"""cli main package.""" diff --git a/recorder/src/sqlquack_record/cli/main/run_recorder.py b/recorder/src/sqlquack_record/cli/main/run_recorder.py new file mode 100644 index 000000000..3893dd464 --- /dev/null +++ b/recorder/src/sqlquack_record/cli/main/run_recorder.py @@ -0,0 +1,77 @@ +"""The `sqlquack-record` command.""" + +from __future__ import annotations + +import argparse +import os +import sys +from datetime import UTC, datetime +from pathlib import Path + +from sqlquack_record.constants import ( + CONNECTION_ENVIRONMENT, + CONNECTION_OVERRIDES, + DEFAULT_BATCH_SIZE, + DEFAULT_PENDING_DIR, + DEFAULT_RESULTS_DIR, + WAREHOUSE_ENVIRONMENT, +) +from sqlquack_record.errors import RecorderError +from sqlquack_record.models import PendingCase, RecordSummary +from sqlquack_record.pending.main.load_pending import load_pending +from sqlquack_record.recording.main.record_pending import record_pending +from sqlquack_record.snowflake.main.open_snowflake_sessions import snowflake_sessions +from sqlquack_record.snowflake.models import ConnectionSettings + + +def main(argv: list[str] | None = None) -> int: + """Record every pending case on Snowflake.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="sqlquack-record", description=__doc__ + ) + parser.add_argument("--pending", type=Path, default=Path(DEFAULT_PENDING_DIR)) + parser.add_argument("--results", type=Path, default=Path(DEFAULT_RESULTS_DIR)) + parser.add_argument("--connection", default=os.environ.get(CONNECTION_ENVIRONMENT)) + parser.add_argument("--warehouse", default=os.environ.get(WAREHOUSE_ENVIRONMENT)) + parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE) + parser.add_argument("--dry-run", action="store_true") + arguments: argparse.Namespace = parser.parse_args(argv) + try: + cases: list[PendingCase] = load_pending(arguments.pending) + print( + f"sqlquack-record: {len(cases)} pending cases in {arguments.pending}", file=sys.stderr + ) + if arguments.dry_run or not cases: + for case in cases: + print(f"{case.case_hash} {case.case_reference}") + return 0 + settings: ConnectionSettings = ConnectionSettings( + connection_name=arguments.connection, + warehouse=arguments.warehouse, + overrides=tuple( + (argument, os.environ[variable]) + for variable, argument in sorted(CONNECTION_OVERRIDES.items()) + if os.environ.get(variable) + ), + ) + print("sqlquack-record: connecting to Snowflake…", file=sys.stderr) + summary: RecordSummary = record_pending( + cases, + arguments.results, + snowflake_sessions(settings), + arguments.batch_size, + lambda: datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"), + ) + except RecorderError as error: + print(f"sqlquack-record: failed: {error}", file=sys.stderr) + return 1 + print( + f"sqlquack-record: done, recorded {summary.recorded} results " + f"({summary.errors} warehouse errors) into {arguments.results}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/recorder/src/sqlquack_record/constants.py b/recorder/src/sqlquack_record/constants.py new file mode 100644 index 000000000..d5a4e2736 --- /dev/null +++ b/recorder/src/sqlquack_record/constants.py @@ -0,0 +1,23 @@ +"""Recorder identity, file formats and defaults.""" + +from __future__ import annotations + +RECORDER_NAME: str = "sqlquack-record" +RECORDER_VERSION: str = "0.1.0" +EVIDENCE_FORMAT: int = 1 +DEFAULT_PENDING_DIR: str = "evidence/pending" +DEFAULT_RESULTS_DIR: str = "evidence/results" +DEFAULT_BATCH_SIZE: int = 20 +QUERY_TAG: str = "sqlquack-record" +CONNECTION_ENVIRONMENT: str = "SQLQUACK_SNOWFLAKE_CONNECTION" +WAREHOUSE_ENVIRONMENT: str = "SQLQUACK_SNOWFLAKE_WAREHOUSE" +CONNECTION_OVERRIDES: dict[str, str] = { + "SNOWFLAKE_ACCOUNT": "account", + "SNOWFLAKE_USER": "user", + "SNOWFLAKE_ROLE": "role", + "SNOWFLAKE_AUTHENTICATOR": "authenticator", + "SNOWFLAKE_HOST": "host", +} +OAUTH_AUTHORIZATION_CODE: str = "oauth_authorization_code" +RECORDABLE_SQLSTATE_CLASSES: frozenset[str] = frozenset({"0A", "21", "22", "42"}) +HASH_NAME_LENGTH: int = 64 diff --git a/recorder/src/sqlquack_record/errors.py b/recorder/src/sqlquack_record/errors.py new file mode 100644 index 000000000..e0788b99e --- /dev/null +++ b/recorder/src/sqlquack_record/errors.py @@ -0,0 +1,7 @@ +"""Recorder failures.""" + +from __future__ import annotations + + +class RecorderError(Exception): + """A failure that stops recording without writing a result.""" diff --git a/recorder/src/sqlquack_record/models.py b/recorder/src/sqlquack_record/models.py new file mode 100644 index 000000000..de52c557d --- /dev/null +++ b/recorder/src/sqlquack_record/models.py @@ -0,0 +1,70 @@ +"""Pending cases, statement results and recorded metadata.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pyarrow as pa + +SessionValue = bool | int | str + + +@dataclass(frozen=True) +class PendingCase: + """One case to record, as `sqlquack pending` writes it.""" + + case_hash: str + case_reference: str + source_dialect: str + session_parameters: tuple[tuple[str, SessionValue], ...] + source_sql: str + duckdb_version: str + + +@dataclass(frozen=True) +class ColumnDescription: + """One result column as the connector describes it.""" + + name: str + type_code: int + type_name: str + precision: int | None + scale: int | None + internal_size: int | None + nullable: bool | None + + +@dataclass(frozen=True) +class RecordedError: + """A warehouse error recorded as a case outcome.""" + + errno: int | None + sqlstate: str | None + message: str + + +@dataclass(frozen=True) +class StatementResult: + """What the warehouse returned for one case: rows as Arrow, or an error.""" + + query_id: str | None + columns: tuple[ColumnDescription, ...] + table: pa.Table | None + error: RecordedError | None + + +@dataclass(frozen=True) +class RecordingContext: + """Facts about the recording session written into every result.""" + + warehouse_version: str + connector: str + recorded_at: str + + +@dataclass(frozen=True) +class RecordSummary: + """What a recording run did.""" + + recorded: int + errors: int diff --git a/recorder/src/sqlquack_record/pending/__init__.py b/recorder/src/sqlquack_record/pending/__init__.py new file mode 100644 index 000000000..7be7a0066 --- /dev/null +++ b/recorder/src/sqlquack_record/pending/__init__.py @@ -0,0 +1 @@ +"""pending package.""" diff --git a/recorder/src/sqlquack_record/pending/main/__init__.py b/recorder/src/sqlquack_record/pending/main/__init__.py new file mode 100644 index 000000000..983c05170 --- /dev/null +++ b/recorder/src/sqlquack_record/pending/main/__init__.py @@ -0,0 +1 @@ +"""pending main package.""" diff --git a/recorder/src/sqlquack_record/pending/main/load_pending.py b/recorder/src/sqlquack_record/pending/main/load_pending.py new file mode 100644 index 000000000..4be8e6fd5 --- /dev/null +++ b/recorder/src/sqlquack_record/pending/main/load_pending.py @@ -0,0 +1,66 @@ +"""Load the pending case files `sqlquack pending` wrote.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from sqlquack_record.constants import EVIDENCE_FORMAT, HASH_NAME_LENGTH +from sqlquack_record.errors import RecorderError +from sqlquack_record.models import PendingCase, SessionValue + +_KEYS: frozenset[str] = frozenset( + { + "format", + "case_hash", + "case_reference", + "source_dialect", + "session_parameters", + "source_sql", + "duckdb_version", + } +) + + +def load_pending(directory: Path) -> list[PendingCase]: + """Every pending case in `directory`, sorted by case reference.""" + if not directory.is_dir(): + return [] + cases: list[PendingCase] = [ + _parse(path) for path in sorted(directory.glob("*.json")) if _is_case_file(path) + ] + return sorted(cases, key=lambda case: case.case_reference) + + +def _is_case_file(path: Path) -> bool: + stem: str = path.stem + return len(stem) == HASH_NAME_LENGTH and all( + character in "0123456789abcdef" for character in stem + ) + + +def _parse(path: Path) -> PendingCase: + document: object = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict) or set(document) != _KEYS: + raise RecorderError(f"{path.name}: not a pending case file") + if document["format"] != EVIDENCE_FORMAT or document["case_hash"] != path.stem: + raise RecorderError(f"{path.name}: unexpected format or case hash") + parameters: object = document["session_parameters"] + if not isinstance(parameters, dict): + raise RecorderError(f"{path.name}: session_parameters is not an object") + return PendingCase( + case_hash=str(document["case_hash"]), + case_reference=str(document["case_reference"]), + source_dialect=str(document["source_dialect"]), + session_parameters=tuple( + sorted((str(key), _value(path, value)) for key, value in parameters.items()) + ), + source_sql=str(document["source_sql"]), + duckdb_version=str(document["duckdb_version"]), + ) + + +def _value(path: Path, value: object) -> SessionValue: + if isinstance(value, bool | int | str): + return value + raise RecorderError(f"{path.name}: unsupported session parameter value {value!r}") diff --git a/recorder/src/sqlquack_record/recording/__init__.py b/recorder/src/sqlquack_record/recording/__init__.py new file mode 100644 index 000000000..f2855e10f --- /dev/null +++ b/recorder/src/sqlquack_record/recording/__init__.py @@ -0,0 +1 @@ +"""recording package.""" diff --git a/recorder/src/sqlquack_record/recording/_helpers/__init__.py b/recorder/src/sqlquack_record/recording/_helpers/__init__.py new file mode 100644 index 000000000..8be524a70 --- /dev/null +++ b/recorder/src/sqlquack_record/recording/_helpers/__init__.py @@ -0,0 +1 @@ +"""recording _helpers package.""" diff --git a/recorder/src/sqlquack_record/recording/_helpers/batches.py b/recorder/src/sqlquack_record/recording/_helpers/batches.py new file mode 100644 index 000000000..91c3b5ccc --- /dev/null +++ b/recorder/src/sqlquack_record/recording/_helpers/batches.py @@ -0,0 +1,24 @@ +"""Grouping and batching pending cases.""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence + +from sqlquack_record.models import PendingCase, SessionValue + + +def by_session( + cases: Sequence[PendingCase], +) -> dict[tuple[tuple[str, SessionValue], ...], list[PendingCase]]: + """Cases grouped by their session parameters, in first-seen order.""" + groups: dict[tuple[tuple[str, SessionValue], ...], list[PendingCase]] = {} + for case in cases: + groups.setdefault(case.session_parameters, []).append(case) + return groups + + +def batches(cases: Sequence[PendingCase], size: int) -> Iterator[Sequence[PendingCase]]: + """Consecutive slices of at most `size` cases.""" + step: int = max(size, 1) + for start in range(0, len(cases), step): + yield cases[start : start + step] diff --git a/recorder/src/sqlquack_record/recording/main/__init__.py b/recorder/src/sqlquack_record/recording/main/__init__.py new file mode 100644 index 000000000..061c8fc05 --- /dev/null +++ b/recorder/src/sqlquack_record/recording/main/__init__.py @@ -0,0 +1 @@ +"""recording main package.""" diff --git a/recorder/src/sqlquack_record/recording/main/record_pending.py b/recorder/src/sqlquack_record/recording/main/record_pending.py new file mode 100644 index 000000000..6946117ca --- /dev/null +++ b/recorder/src/sqlquack_record/recording/main/record_pending.py @@ -0,0 +1,59 @@ +"""Record every pending case, batching cases that share session parameters.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from pathlib import Path + +from sqlquack_record.errors import RecorderError +from sqlquack_record.models import PendingCase, RecordingContext, RecordSummary, StatementResult +from sqlquack_record.recording._helpers.batches import batches, by_session +from sqlquack_record.results.main.write_result import write_result +from sqlquack_record.types import SessionFactory, WarehouseSession + + +def record_pending( + cases: Sequence[PendingCase], + results: Path, + open_session: SessionFactory, + batch_size: int, + clock: Callable[[], str], +) -> RecordSummary: + """Run each unrecorded case once and store its result; never overwrite a stored result.""" + results.mkdir(parents=True, exist_ok=True) + unrecorded: list[PendingCase] = [ + case for case in cases if not (results / f"{case.case_hash}.json").exists() + ] + recorded: int = 0 + errors: int = 0 + for parameters, group in by_session(unrecorded).items(): + session: WarehouseSession = open_session(parameters) + try: + context: RecordingContext = RecordingContext( + warehouse_version=session.warehouse_version(), + connector=session.connector(), + recorded_at=clock(), + ) + for batch in batches(group, batch_size): + outcomes: list[StatementResult] = _run(session, batch) + for case, outcome in zip(batch, outcomes, strict=True): + write_result(results, case, outcome, context) + recorded += 1 + errors += outcome.error is not None + finally: + session.close() + return RecordSummary(recorded=recorded, errors=errors) + + +def _run(session: WarehouseSession, batch: Sequence[PendingCase]) -> list[StatementResult]: + statements: list[str] = [case.source_sql for case in batch] + batched: list[StatementResult] | None = ( + session.run_batch(statements) if len(statements) > 1 else None + ) + if batched is not None: + if len(batched) != len(statements): + raise RecorderError( + f"the warehouse returned {len(batched)} results for {len(statements)} statements" + ) + return batched + return [session.run_one(statement) for statement in statements] diff --git a/recorder/src/sqlquack_record/results/__init__.py b/recorder/src/sqlquack_record/results/__init__.py new file mode 100644 index 000000000..ce4b6b24f --- /dev/null +++ b/recorder/src/sqlquack_record/results/__init__.py @@ -0,0 +1 @@ +"""results package.""" diff --git a/recorder/src/sqlquack_record/results/_helpers/__init__.py b/recorder/src/sqlquack_record/results/_helpers/__init__.py new file mode 100644 index 000000000..64c372411 --- /dev/null +++ b/recorder/src/sqlquack_record/results/_helpers/__init__.py @@ -0,0 +1 @@ +"""results _helpers package.""" diff --git a/recorder/src/sqlquack_record/results/_helpers/files.py b/recorder/src/sqlquack_record/results/_helpers/files.py new file mode 100644 index 000000000..d5116e53d --- /dev/null +++ b/recorder/src/sqlquack_record/results/_helpers/files.py @@ -0,0 +1,18 @@ +"""Exclusive, append-only file creation.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from sqlquack_record.errors import RecorderError + + +def create_exclusively(path: Path, content: bytes) -> None: + """Write `content` to `path`, failing if anything already exists there.""" + try: + descriptor: int = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) + except FileExistsError as error: + raise RecorderError(f"{path.name} already exists; results are append-only") from error + with os.fdopen(descriptor, "wb") as handle: + handle.write(content) diff --git a/recorder/src/sqlquack_record/results/main/__init__.py b/recorder/src/sqlquack_record/results/main/__init__.py new file mode 100644 index 000000000..ef9e291a8 --- /dev/null +++ b/recorder/src/sqlquack_record/results/main/__init__.py @@ -0,0 +1 @@ +"""results main package.""" diff --git a/recorder/src/sqlquack_record/results/main/write_result.py b/recorder/src/sqlquack_record/results/main/write_result.py new file mode 100644 index 000000000..587d431b0 --- /dev/null +++ b/recorder/src/sqlquack_record/results/main/write_result.py @@ -0,0 +1,70 @@ +"""Write one recorded result: the Arrow IPC file, then its metadata.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pyarrow as pa + +from sqlquack_record.constants import EVIDENCE_FORMAT, RECORDER_NAME, RECORDER_VERSION +from sqlquack_record.models import PendingCase, RecordingContext, StatementResult +from sqlquack_record.results._helpers.files import create_exclusively + + +def write_result( + directory: Path, case: PendingCase, result: StatementResult, context: RecordingContext +) -> None: + """Store `result` for `case` without converting a single value.""" + arrow_sha256: str | None = None + row_count: int | None = None + if result.table is not None: + arrow_bytes: bytes = _ipc_file(result.table) + create_exclusively(directory / f"{case.case_hash}.arrow", arrow_bytes) + arrow_sha256 = hashlib.sha256(arrow_bytes).hexdigest() + row_count = result.table.num_rows + metadata: dict[str, object] = { + "format": EVIDENCE_FORMAT, + "case_hash": case.case_hash, + "outcome": "rows" if result.table is not None else "error", + "recorder": f"{RECORDER_NAME} {RECORDER_VERSION}", + "connector": context.connector, + "query_id": result.query_id, + "warehouse_version": context.warehouse_version, + "duckdb_version": case.duckdb_version, + "recorded_at": context.recorded_at, + "session_parameters": dict(case.session_parameters), + "columns": [ + { + "name": column.name, + "type_code": column.type_code, + "type_name": column.type_name, + "precision": column.precision, + "scale": column.scale, + "internal_size": column.internal_size, + "nullable": column.nullable, + } + for column in result.columns + ], + "row_count": row_count, + "arrow_sha256": arrow_sha256, + "error": None + if result.error is None + else { + "errno": result.error.errno, + "sqlstate": result.error.sqlstate, + "message": result.error.message, + }, + } + create_exclusively( + directory / f"{case.case_hash}.json", + (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode("utf-8"), + ) + + +def _ipc_file(table: pa.Table) -> bytes: + sink: pa.BufferOutputStream = pa.BufferOutputStream() + with pa.ipc.new_file(sink, table.schema) as writer: + writer.write_table(table) + return sink.getvalue().to_pybytes() diff --git a/recorder/src/sqlquack_record/snowflake/__init__.py b/recorder/src/sqlquack_record/snowflake/__init__.py new file mode 100644 index 000000000..ac0609406 --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/__init__.py @@ -0,0 +1 @@ +"""snowflake package.""" diff --git a/recorder/src/sqlquack_record/snowflake/_helpers/__init__.py b/recorder/src/sqlquack_record/snowflake/_helpers/__init__.py new file mode 100644 index 000000000..248ca7ea2 --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/_helpers/__init__.py @@ -0,0 +1 @@ +"""snowflake _helpers package.""" diff --git a/recorder/src/sqlquack_record/snowflake/_helpers/session.py b/recorder/src/sqlquack_record/snowflake/_helpers/session.py new file mode 100644 index 000000000..fcb1f443d --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/_helpers/session.py @@ -0,0 +1,114 @@ +"""A Snowflake connection driven by the recorder.""" + +from __future__ import annotations + +from collections.abc import Sequence +from importlib.metadata import version +from typing import Any + +from snowflake.connector.constants import FIELD_ID_TO_NAME +from snowflake.connector.errors import ProgrammingError + +from sqlquack_record.constants import RECORDABLE_SQLSTATE_CLASSES +from sqlquack_record.errors import RecorderError +from sqlquack_record.models import ColumnDescription, RecordedError, StatementResult + + +class SnowflakeSession: + """Runs case SQL and returns the connector's Arrow results untouched.""" + + def __init__(self, connection: Any) -> None: + """Wrap an open connection.""" + self._connection: Any = connection + + def warehouse_version(self) -> str: + """`CURRENT_VERSION()`.""" + return str(self._scalar("SELECT CURRENT_VERSION()")) + + def connector(self) -> str: + """The installed connector name and version.""" + return f"snowflake-connector-python {version('snowflake-connector-python')}" + + def current_warehouse(self) -> str | None: + """`CURRENT_WAREHOUSE()`, absent when none is in use.""" + value: object = self._scalar("SELECT CURRENT_WAREHOUSE()") + return None if value is None else str(value) + + def run_batch(self, statements: Sequence[str]) -> list[StatementResult] | None: + """Run statements as one multi-statement request.""" + cursor: Any = self._connection.cursor() + try: + cursor.execute(";\n".join(statements), num_statements=len(statements)) + results: list[StatementResult] = [_fetch(cursor)] + for _ in statements[1:]: + if not cursor.nextset(): + raise RecorderError("the warehouse returned fewer results than statements") + results.append(_fetch(cursor)) + except ProgrammingError as error: + _require_recordable(error) + return None + finally: + cursor.close() + return results + + def run_one(self, statement: str) -> StatementResult: + """Run one statement; a recordable SQL error becomes the result.""" + cursor: Any = self._connection.cursor() + try: + cursor.execute(statement) + return _fetch(cursor) + except ProgrammingError as error: + _require_recordable(error) + return StatementResult( + query_id=error.sfqid, + columns=(), + table=None, + error=RecordedError( + errno=error.errno, sqlstate=error.sqlstate, message=str(error.msg) + ), + ) + finally: + cursor.close() + + def close(self) -> None: + """Close the connection.""" + self._connection.close() + + def _scalar(self, statement: str) -> object: + cursor: Any = self._connection.cursor() + try: + row: tuple[object, ...] | None = cursor.execute(statement).fetchone() + finally: + cursor.close() + return None if row is None else row[0] + + +def _fetch(cursor: Any) -> StatementResult: + table: Any = cursor.fetch_arrow_all(force_return_table=True) + return StatementResult( + query_id=cursor.sfqid, + columns=tuple(_describe(column) for column in cursor.description), + table=table, + error=None, + ) + + +def _describe(column: Any) -> ColumnDescription: + return ColumnDescription( + name=str(column.name), + type_code=int(column.type_code), + type_name=str(FIELD_ID_TO_NAME.get(column.type_code, column.type_code)), + precision=column.precision, + scale=column.scale, + internal_size=column.internal_size, + nullable=column.is_nullable, + ) + + +def _require_recordable(error: ProgrammingError) -> None: + sqlstate: str = str(error.sqlstate or "") + if sqlstate[:2] not in RECORDABLE_SQLSTATE_CLASSES: + raise RecorderError( + f"Snowflake error {error.errno} (SQLSTATE {sqlstate or 'unknown'}) is not a case " + f"outcome; nothing was recorded: {error.msg}" + ) from error diff --git a/recorder/src/sqlquack_record/snowflake/main/__init__.py b/recorder/src/sqlquack_record/snowflake/main/__init__.py new file mode 100644 index 000000000..610df4117 --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/main/__init__.py @@ -0,0 +1 @@ +"""snowflake main package.""" diff --git a/recorder/src/sqlquack_record/snowflake/main/open_snowflake_sessions.py b/recorder/src/sqlquack_record/snowflake/main/open_snowflake_sessions.py new file mode 100644 index 000000000..89733ebb8 --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/main/open_snowflake_sessions.py @@ -0,0 +1,40 @@ +"""Open Snowflake sessions with the owner's connection settings.""" + +from __future__ import annotations + +from typing import Any + +import snowflake.connector + +from sqlquack_record.constants import QUERY_TAG +from sqlquack_record.errors import RecorderError +from sqlquack_record.models import SessionValue +from sqlquack_record.snowflake._helpers.session import SnowflakeSession +from sqlquack_record.snowflake.models import ConnectionSettings +from sqlquack_record.types import SessionFactory, WarehouseSession + + +def snowflake_sessions(settings: ConnectionSettings) -> SessionFactory: + """A factory opening one Snowflake session per set of session parameters.""" + + def open_session(parameters: tuple[tuple[str, SessionValue], ...]) -> WarehouseSession: + arguments: dict[str, Any] = { + "session_parameters": {**dict(parameters), "QUERY_TAG": QUERY_TAG}, + "arrow_number_to_decimal": True, + "client_store_temporary_credential": True, + "oauth_enable_refresh_tokens": True, + **dict(settings.overrides), + } + if settings.connection_name is not None: + arguments["connection_name"] = settings.connection_name + if settings.warehouse is not None: + arguments["warehouse"] = settings.warehouse + session: SnowflakeSession = SnowflakeSession(snowflake.connector.connect(**arguments)) + if session.current_warehouse() is None: + session.close() + raise RecorderError( + "no warehouse is in use; set one in the connection or with --warehouse" + ) + return session + + return open_session diff --git a/recorder/src/sqlquack_record/snowflake/models.py b/recorder/src/sqlquack_record/snowflake/models.py new file mode 100644 index 000000000..58ed65da7 --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/models.py @@ -0,0 +1,14 @@ +"""Connection settings.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ConnectionSettings: + """How to reach Snowflake: a named connection plus explicit overrides, never stored in the repo.""" + + connection_name: str | None + warehouse: str | None + overrides: tuple[tuple[str, str], ...] diff --git a/recorder/src/sqlquack_record/types.py b/recorder/src/sqlquack_record/types.py new file mode 100644 index 000000000..4247c8334 --- /dev/null +++ b/recorder/src/sqlquack_record/types.py @@ -0,0 +1,35 @@ +"""The warehouse session the recorder drives.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Protocol + +from sqlquack_record.models import SessionValue, StatementResult + + +class WarehouseSession(Protocol): + """A warehouse connection with fixed session parameters.""" + + def warehouse_version(self) -> str: + """The warehouse version.""" + ... + + def connector(self) -> str: + """The client name and version.""" + ... + + def run_batch(self, statements: Sequence[str]) -> list[StatementResult] | None: + """Run statements in one request; `None` when a statement raised a recordable SQL error.""" + ... + + def run_one(self, statement: str) -> StatementResult: + """Run one statement, returning a recordable SQL error as an error result.""" + ... + + def close(self) -> None: + """Close the connection.""" + ... + + +SessionFactory = Callable[[tuple[tuple[str, SessionValue], ...]], WarehouseSession] diff --git a/recorder/tests/__init__.py b/recorder/tests/__init__.py new file mode 100644 index 000000000..9a7ab5981 --- /dev/null +++ b/recorder/tests/__init__.py @@ -0,0 +1 @@ +"""Recorder tests.""" diff --git a/recorder/tests/recording/__init__.py b/recorder/tests/recording/__init__.py new file mode 100644 index 000000000..9d6fc64b7 --- /dev/null +++ b/recorder/tests/recording/__init__.py @@ -0,0 +1 @@ +"""Recording tests.""" diff --git a/recorder/tests/recording/_test_types.py b/recorder/tests/recording/_test_types.py new file mode 100644 index 000000000..235f2e98e --- /dev/null +++ b/recorder/tests/recording/_test_types.py @@ -0,0 +1,19 @@ +"""Test types for recording pending cases.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RecordTestCase: + description: str + statements: tuple[str, ...] + failing: frozenset[str] + parameters: tuple[tuple[tuple[str, bool | int | str], ...], ...] + preexisting: tuple[str, ...] + expected_recorded: int + expected_errors: int + expected_sessions: int + expected_batches: int + expected_outcomes: tuple[str, ...] diff --git a/recorder/tests/recording/helpers.py b/recorder/tests/recording/helpers.py new file mode 100644 index 000000000..4ee13a3a3 --- /dev/null +++ b/recorder/tests/recording/helpers.py @@ -0,0 +1,140 @@ +"""A fake warehouse session and helpers for recorder tests.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Sequence +from decimal import Decimal +from pathlib import Path + +import pyarrow as pa + +from sqlquack_record.models import ( + ColumnDescription, + PendingCase, + RecordedError, + SessionValue, + StatementResult, +) +from sqlquack_record.recording.main.record_pending import record_pending +from sqlquack_record.types import WarehouseSession + +_COLUMNS: tuple[ColumnDescription, ...] = ( + ColumnDescription("ORDINAL", 0, "FIXED", 1, 0, None, False), + ColumnDescription("VALUE", 0, "FIXED", 38, 2, None, True), +) + + +def fake_table(statement: str) -> pa.Table: + """The rows the fake warehouse returns for a statement.""" + return pa.table( + { + "ORDINAL": pa.array([1, 2], type=pa.int8()), + "VALUE": pa.array([Decimal(len(statement)) / 100, None], type=pa.decimal128(38, 2)), + } + ) + + +class FakeSession: + """Returns scripted results; statements in `failing` raise a recordable SQL error.""" + + def __init__(self, failing: frozenset[str], log: list[str]) -> None: + self._failing: frozenset[str] = failing + self._log: list[str] = log + + def warehouse_version(self) -> str: + return "0.0.0-fake" + + def connector(self) -> str: + return "fake-connector 0" + + def run_batch(self, statements: Sequence[str]) -> list[StatementResult] | None: + self._log.append("batch") + if any(statement in self._failing for statement in statements): + return None + return [self._rows(statement) for statement in statements] + + def run_one(self, statement: str) -> StatementResult: + self._log.append("one") + if statement in self._failing: + return StatementResult( + query_id="fake-error-query", + columns=(), + table=None, + error=RecordedError(errno=100051, sqlstate="22012", message="Division by zero"), + ) + return self._rows(statement) + + def close(self) -> None: + self._log.append("close") + + def _rows(self, statement: str) -> StatementResult: + return StatementResult( + query_id=f"fake-query-{len(statement)}", + columns=_COLUMNS, + table=fake_table(statement), + error=None, + ) + + +def pending_case(statement: str, parameters: tuple[tuple[str, SessionValue], ...]) -> PendingCase: + """A pending case whose hash derives from its statement.""" + return PendingCase( + case_hash=hashlib.sha256(statement.encode("utf-8")).hexdigest(), + case_reference=f"fake.toml#{len(statement)}", + source_dialect="snowflake", + session_parameters=parameters, + source_sql=statement, + duckdb_version="v2.0.0", + ) + + +def record( + directory: Path, + statements: Sequence[str], + failing: frozenset[str], + parameters: Sequence[tuple[tuple[str, SessionValue], ...]], + preexisting: Sequence[str], +) -> tuple[int, int, list[str], list[str]]: + """Record with a fake session; returns counts, the session log and each case's outcome.""" + cases: list[PendingCase] = [ + pending_case(statement, parameters[index % len(parameters)]) + for index, statement in enumerate(statements) + ] + results: Path = directory / "results" + results.mkdir() + for statement in preexisting: + (results / f"{pending_case(statement, ()).case_hash}.json").write_text( + "{}", encoding="utf-8" + ) + log: list[str] = [] + + def open_session(_: tuple[tuple[str, SessionValue], ...]) -> WarehouseSession: + log.append("open") + return FakeSession(failing, log) + + summary = record_pending(cases, results, open_session, 10, lambda: "2026-01-01T00:00:00Z") + outcomes: list[str] = [_outcome(results, case) for case in cases] + return summary.recorded, summary.errors, log, outcomes + + +def _outcome(results: Path, case: PendingCase) -> str: + metadata: dict[str, object] = json.loads( + (results / f"{case.case_hash}.json").read_text(encoding="utf-8") + ) + if not metadata: + return "preexisting" + arrow_path: Path = results / f"{case.case_hash}.arrow" + if metadata["outcome"] == "error": + return "error" if not arrow_path.exists() else "error-with-arrow" + arrow_bytes: bytes = arrow_path.read_bytes() + table: pa.Table = pa.ipc.open_file(pa.BufferReader(arrow_bytes)).read_all() + intact: bool = ( + table.equals(fake_table(case.source_sql)) + and metadata["arrow_sha256"] == hashlib.sha256(arrow_bytes).hexdigest() + and metadata["row_count"] == table.num_rows + and metadata["case_hash"] == case.case_hash + and metadata["warehouse_version"] == "0.0.0-fake" + ) + return "rows" if intact else "rows-altered" diff --git a/recorder/tests/recording/test_record_pending.py b/recorder/tests/recording/test_record_pending.py new file mode 100644 index 000000000..e461d2d2c --- /dev/null +++ b/recorder/tests/recording/test_record_pending.py @@ -0,0 +1,85 @@ +"""Recording pending cases against a fake warehouse session.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from recorder.tests.recording._test_types import RecordTestCase +from recorder.tests.recording.helpers import record + +_UTC: tuple[tuple[str, bool | int | str], ...] = (("TIMEZONE", "UTC"),) +_WEEKS: tuple[tuple[str, bool | int | str], ...] = (("TIMEZONE", "UTC"), ("WEEK_START", 1)) + + +@pytest.mark.parametrize( + "test_case", + ( + RecordTestCase( + description="rows are stored as the connector's Arrow table, unconverted", + statements=("SELECT 1", "SELECT 22"), + failing=frozenset(), + parameters=(_UTC,), + preexisting=(), + expected_recorded=2, + expected_errors=0, + expected_sessions=1, + expected_batches=1, + expected_outcomes=("rows", "rows"), + ), + RecordTestCase( + description="a batch with a SQL error is rerun case by case and the error recorded", + statements=("SELECT 1", "SELECT 1 / 0"), + failing=frozenset({"SELECT 1 / 0"}), + parameters=(_UTC,), + preexisting=(), + expected_recorded=2, + expected_errors=1, + expected_sessions=1, + expected_batches=1, + expected_outcomes=("rows", "error"), + ), + RecordTestCase( + description="an existing result is never re-recorded", + statements=("SELECT 1", "SELECT 22"), + failing=frozenset(), + parameters=(_UTC,), + preexisting=("SELECT 1",), + expected_recorded=1, + expected_errors=0, + expected_sessions=1, + expected_batches=0, + expected_outcomes=("preexisting", "rows"), + ), + RecordTestCase( + description="each set of session parameters gets its own session", + statements=("SELECT 1", "SELECT 22"), + failing=frozenset(), + parameters=(_UTC, _WEEKS), + preexisting=(), + expected_recorded=2, + expected_errors=0, + expected_sessions=2, + expected_batches=0, + expected_outcomes=("rows", "rows"), + ), + ), + ids=lambda case: case.description, +) +def test_given_pending_cases_when_recording_then_results_are_stored_once_and_unconverted( + test_case: RecordTestCase, tmp_path: Path +) -> None: + recorded, errors, log, outcomes = record( + tmp_path, + test_case.statements, + test_case.failing, + test_case.parameters, + test_case.preexisting, + ) + + assert recorded == test_case.expected_recorded + assert errors == test_case.expected_errors + assert log.count("open") == test_case.expected_sessions + assert log.count("batch") == test_case.expected_batches + assert tuple(outcomes) == test_case.expected_outcomes diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 000000000..bc2f67782 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository tooling.""" diff --git a/scripts/check_evidence_append_only.py b/scripts/check_evidence_append_only.py new file mode 100644 index 000000000..77d7ee9c5 --- /dev/null +++ b/scripts/check_evidence_append_only.py @@ -0,0 +1,14 @@ +"""Direct wrapper for the append-only evidence guard.""" + +from __future__ import annotations + +from scripts.evidence_guard.main.check import run_evidence_guard + + +def main(argv: list[str] | None = None) -> int: + """Fail when a change modifies or deletes a recorded result, or adds an invalid one.""" + return run_evidence_guard(argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/evidence_guard/__init__.py b/scripts/evidence_guard/__init__.py new file mode 100644 index 000000000..8e9ce6cb0 --- /dev/null +++ b/scripts/evidence_guard/__init__.py @@ -0,0 +1 @@ +"""Append-only evidence guard.""" diff --git a/scripts/evidence_guard/_helpers/__init__.py b/scripts/evidence_guard/_helpers/__init__.py new file mode 100644 index 000000000..dd0df9b57 --- /dev/null +++ b/scripts/evidence_guard/_helpers/__init__.py @@ -0,0 +1 @@ +"""Guard helpers.""" diff --git a/scripts/evidence_guard/_helpers/git.py b/scripts/evidence_guard/_helpers/git.py new file mode 100644 index 000000000..fa30ca470 --- /dev/null +++ b/scripts/evidence_guard/_helpers/git.py @@ -0,0 +1,33 @@ +"""Reading changes and blobs from Git.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from scripts.evidence_guard.constants import RESULTS_DIR +from scripts.evidence_guard.models import ChangedPath + + +def changed_results(repository: Path, base: str, head: str) -> list[ChangedPath]: + """Every change under the results directory between the merge base of `base` and `head`.""" + output: str = subprocess.run( + ("git", "diff", "--name-status", "--no-renames", f"{base}...{head}", "--", RESULTS_DIR), + cwd=repository, + check=True, + capture_output=True, + text=True, + ).stdout + return [ + ChangedPath(status=line.split("\t", 1)[0], path=line.split("\t", 1)[1]) + for line in output.splitlines() + if "\t" in line + ] + + +def blob(repository: Path, head: str, path: str) -> bytes | None: + """The bytes of `path` at `head`, absent when it does not exist there.""" + result: subprocess.CompletedProcess[bytes] = subprocess.run( + ("git", "show", f"{head}:{path}"), cwd=repository, check=False, capture_output=True + ) + return result.stdout if result.returncode == 0 else None diff --git a/scripts/evidence_guard/_helpers/metadata.py b/scripts/evidence_guard/_helpers/metadata.py new file mode 100644 index 000000000..ad5845e39 --- /dev/null +++ b/scripts/evidence_guard/_helpers/metadata.py @@ -0,0 +1,53 @@ +"""Checking one added result.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path, PurePosixPath + +from scripts.evidence_guard._helpers.git import blob +from scripts.evidence_guard.constants import ( + EVIDENCE_FORMAT, + HASH_LENGTH, + METADATA_KEYS, + RECORDER_PREFIX, + RESULTS_DIR, +) + + +def added_result_problems(repository: Path, head: str, path: str) -> list[str]: + """Why the added file `path` is not a valid new result, if it is not.""" + name: PurePosixPath = PurePosixPath(path) + stem: str = name.stem + if len(stem) != HASH_LENGTH or any(character not in "0123456789abcdef" for character in stem): + return [f"{path}: result files are named by a lower-case SHA-256 case hash"] + if name.suffix == ".arrow": + if blob(repository, head, f"{RESULTS_DIR}/{stem}.json") is None: + return [f"{path}: an Arrow file needs its metadata file"] + return [] + if name.suffix != ".json": + return [f"{path}: only .json and .arrow result files are allowed"] + return _metadata_problems(repository, head, path, stem) + + +def _metadata_problems(repository: Path, head: str, path: str, stem: str) -> list[str]: + content: bytes | None = blob(repository, head, path) + try: + metadata: object = json.loads(content or b"") + except json.JSONDecodeError: + return [f"{path}: metadata is not JSON"] + if not isinstance(metadata, dict) or set(metadata) != METADATA_KEYS: + return [f"{path}: metadata keys differ from the recorder's format"] + problems: list[str] = [] + if metadata["format"] != EVIDENCE_FORMAT or metadata["case_hash"] != stem: + problems.append(f"{path}: wrong format or case hash") + if not str(metadata["recorder"]).startswith(RECORDER_PREFIX): + problems.append(f"{path}: not written by sqlquack-record") + arrow: bytes | None = blob(repository, head, f"{RESULTS_DIR}/{stem}.arrow") + if metadata["outcome"] == "rows": + if arrow is None or metadata["arrow_sha256"] != hashlib.sha256(arrow).hexdigest(): + problems.append(f"{path}: the Arrow file is missing or does not match arrow_sha256") + elif metadata["outcome"] != "error" or arrow is not None or metadata["error"] is None: + problems.append(f"{path}: an error outcome needs an error and no Arrow file") + return problems diff --git a/scripts/evidence_guard/constants.py b/scripts/evidence_guard/constants.py new file mode 100644 index 000000000..4c2409880 --- /dev/null +++ b/scripts/evidence_guard/constants.py @@ -0,0 +1,27 @@ +"""Evidence guard settings.""" + +from __future__ import annotations + +RESULTS_DIR: str = "evidence/results" +UNGUARDED_FILES: frozenset[str] = frozenset({"README.md"}) +RECORDER_PREFIX: str = "sqlquack-record " +EVIDENCE_FORMAT: int = 1 +METADATA_KEYS: frozenset[str] = frozenset( + { + "format", + "case_hash", + "outcome", + "recorder", + "connector", + "query_id", + "warehouse_version", + "duckdb_version", + "recorded_at", + "session_parameters", + "columns", + "row_count", + "arrow_sha256", + "error", + } +) +HASH_LENGTH: int = 64 diff --git a/scripts/evidence_guard/main/__init__.py b/scripts/evidence_guard/main/__init__.py new file mode 100644 index 000000000..6e5e89b28 --- /dev/null +++ b/scripts/evidence_guard/main/__init__.py @@ -0,0 +1 @@ +"""Guard entry points.""" diff --git a/scripts/evidence_guard/main/check.py b/scripts/evidence_guard/main/check.py new file mode 100644 index 000000000..f59c833dd --- /dev/null +++ b/scripts/evidence_guard/main/check.py @@ -0,0 +1,43 @@ +"""Append-only evidence guard entry point.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path, PurePosixPath + +from scripts.evidence_guard._helpers.git import changed_results +from scripts.evidence_guard._helpers.metadata import added_result_problems +from scripts.evidence_guard.constants import UNGUARDED_FILES +from scripts.evidence_guard.models import ChangedPath + + +def run_evidence_guard(argv: list[str] | None = None) -> int: + """Reject modified, deleted or invalid recorded results between two revisions.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True) + parser.add_argument("--head", default="HEAD") + parser.add_argument("--repository", type=Path, default=Path.cwd()) + arguments: argparse.Namespace = parser.parse_args(argv) + changes: list[ChangedPath] = changed_results( + arguments.repository, arguments.base, arguments.head + ) + problems: list[str] = [] + for change in changes: + if PurePosixPath(change.path).name in UNGUARDED_FILES: + continue + if change.status != "A": + problems.append( + f"{change.path}: recorded results are append-only (status {change.status})" + ) + continue + problems.extend(added_result_problems(arguments.repository, arguments.head, change.path)) + for problem in problems: + print(f"evidence guard: {problem}", file=sys.stderr) + added: int = sum(change.status == "A" for change in changes) + print( + f"evidence guard: {'FAILED' if problems else 'passed'}, {added} added result files, " + f"{len(problems)} problems", + file=sys.stderr, + ) + return 1 if problems else 0 diff --git a/scripts/evidence_guard/models.py b/scripts/evidence_guard/models.py new file mode 100644 index 000000000..833ca8696 --- /dev/null +++ b/scripts/evidence_guard/models.py @@ -0,0 +1,13 @@ +"""Evidence guard models.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ChangedPath: + """One path a change touches, with Git's status letter.""" + + status: str + path: str diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..0ef5327de --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Repository tests.""" diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py new file mode 100644 index 000000000..7077a39f4 --- /dev/null +++ b/tests/scripts/__init__.py @@ -0,0 +1 @@ +"""Tooling tests.""" diff --git a/tests/scripts/evidence_guard/__init__.py b/tests/scripts/evidence_guard/__init__.py new file mode 100644 index 000000000..57edf6a35 --- /dev/null +++ b/tests/scripts/evidence_guard/__init__.py @@ -0,0 +1 @@ +"""Evidence guard tests.""" diff --git a/tests/scripts/evidence_guard/_test_types.py b/tests/scripts/evidence_guard/_test_types.py new file mode 100644 index 000000000..6fe406cec --- /dev/null +++ b/tests/scripts/evidence_guard/_test_types.py @@ -0,0 +1,13 @@ +"""Test types for the evidence guard.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GuardTestCase: + description: str + change: str + expected_return_code: int + expected_fragment: str diff --git a/tests/scripts/evidence_guard/helpers.py b/tests/scripts/evidence_guard/helpers.py new file mode 100644 index 000000000..0089db75b --- /dev/null +++ b/tests/scripts/evidence_guard/helpers.py @@ -0,0 +1,67 @@ +"""A throwaway Git repository with recorded results.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +from scripts.evidence_guard.main.check import run_evidence_guard + +_RESULTS: str = "evidence/results" + + +def _git(repository: Path, *arguments: str) -> None: + subprocess.run( + ("git", "-c", "user.name=Test", "-c", "user.email=test@example.com", *arguments), + cwd=repository, + check=True, + capture_output=True, + ) + + +def _write_result(results: Path, seed: str, recorder: str, arrow: bytes, claimed: bytes) -> str: + case_hash: str = hashlib.sha256(seed.encode("utf-8")).hexdigest() + (results / f"{case_hash}.arrow").write_bytes(arrow) + metadata: dict[str, object] = { + "format": 1, + "case_hash": case_hash, + "outcome": "rows", + "recorder": recorder, + "connector": "test", + "query_id": None, + "warehouse_version": "test", + "duckdb_version": "v2.0.0", + "recorded_at": "1970-01-01T00:00:00Z", + "session_parameters": {}, + "columns": [], + "row_count": 0, + "arrow_sha256": hashlib.sha256(claimed).hexdigest(), + "error": None, + } + (results / f"{case_hash}.json").write_text(json.dumps(metadata), encoding="utf-8") + return case_hash + + +def guard_after(repository: Path, change: str) -> int: + """Commit one recorded result as the base, apply `change` on top, and run the guard.""" + results: Path = repository / _RESULTS + results.mkdir(parents=True) + _git(repository, "init", "--quiet", "--initial-branch=main") + stored: str = _write_result(results, "stored", "sqlquack-record 0.1.0", b"rows", b"rows") + _git(repository, "add", "-A") + _git(repository, "commit", "--quiet", "-m", "base") + if change == "edit": + (results / f"{stored}.arrow").write_bytes(b"other rows") + elif change == "delete": + (results / f"{stored}.json").unlink() + elif change == "add": + _write_result(results, "new", "sqlquack-record 0.1.0", b"new rows", b"new rows") + elif change == "add-mismatched": + _write_result(results, "new", "sqlquack-record 0.1.0", b"new rows", b"claimed rows") + elif change == "add-stand-in": + _write_result(results, "new", "test fixture", b"new rows", b"new rows") + _git(repository, "add", "-A") + _git(repository, "commit", "--quiet", "--allow-empty", "-m", "change") + return run_evidence_guard(["--base", "HEAD~1", "--repository", str(repository)]) diff --git a/tests/scripts/evidence_guard/test_check.py b/tests/scripts/evidence_guard/test_check.py new file mode 100644 index 000000000..4e46cf3a1 --- /dev/null +++ b/tests/scripts/evidence_guard/test_check.py @@ -0,0 +1,61 @@ +"""The append-only evidence guard against real Git histories.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.scripts.evidence_guard._test_types import GuardTestCase +from tests.scripts.evidence_guard.helpers import guard_after + + +@pytest.mark.parametrize( + "test_case", + ( + GuardTestCase( + description="an unrelated change passes", + change="none", + expected_return_code=0, + expected_fragment="passed, 0 added", + ), + GuardTestCase( + description="a new recorded result passes", + change="add", + expected_return_code=0, + expected_fragment="passed, 2 added", + ), + GuardTestCase( + description="an edited result is rejected", + change="edit", + expected_return_code=1, + expected_fragment="append-only (status M)", + ), + GuardTestCase( + description="a deleted result is rejected", + change="delete", + expected_return_code=1, + expected_fragment="append-only (status D)", + ), + GuardTestCase( + description="a new result whose Arrow file does not match its metadata is rejected", + change="add-mismatched", + expected_return_code=1, + expected_fragment="does not match arrow_sha256", + ), + GuardTestCase( + description="a result not written by the recorder is rejected", + change="add-stand-in", + expected_return_code=1, + expected_fragment="not written by sqlquack-record", + ), + ), + ids=lambda case: case.description, +) +def test_given_result_change_when_guarding_then_only_valid_additions_pass( + test_case: GuardTestCase, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + return_code: int = guard_after(tmp_path, test_case.change) + + assert return_code == test_case.expected_return_code + assert test_case.expected_fragment in capsys.readouterr().err diff --git a/uv.lock b/uv.lock index a1c9d6e6d..0c89a3589 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,346 @@ version = 1 revision = 3 requires-python = ">=3.12" +[manifest] +members = [ + "sqlquack-record", + "sqlquack-workspace", +] + +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.118" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/8b/5694c8433b5202f34a82def4edee274f7df1eba2919c66d9d389d2202ec8/boto3-1.43.118.tar.gz", hash = "sha256:c0620f799e0f0c222ba42c337b65cdbcd3da38a3121187f634d8926cbbb28a9c", size = 112649, upload-time = "2026-10-22T19:22:09.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/a3/65bb3d64928f3c2da17640b92044084150317f02adf22442cb0c52af2a8b/boto3-1.43.118-py3-none-any.whl", hash = "sha256:ab8f3d2c622ef039338b4f228d97ea55f1abd573d84bb9b9a5ee8cb6780c0ccd", size = 140045, upload-time = "2026-10-22T19:22:08.353Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.118" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/b8/3e55154ddaf1a6d1d4ddda85c0e5983205a3b7657edacd8ebba8c6fd774c/botocore-1.43.118.tar.gz", hash = "sha256:7f3f9d88cc9e847587690f82a42e3c3c292c3026c92c1e60d68a9310018e003f", size = 16420126, upload-time = "2026-10-22T19:22:04.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ce/61b39a8e7a69af5ea43c1c3e1b0065a4866b42ccddf107e16686a3143614/botocore-1.43.118-py3-none-any.whl", hash = "sha256:a45d7ae570674161e4a3542d8c68633bf7503f92d1de781ef7361b79488eda6f", size = 16122016, upload-time = "2026-10-22T19:22:00.414Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + [[package]] name = "fensu" version = "0.27.0" @@ -31,6 +371,281 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/5b/53050980866370c7a41dc7e3e61de86cf67e5848d8560e6a9d166040679a/fensu_cli-0.27.0-py3-none-win_amd64.whl", hash = "sha256:68e91a38dab7e081c4ba504e22a7709169d8d6c470137834adcad116b52fa280", size = 7652792, upload-time = "2026-10-17T05:38:47.26Z" }, ] +[[package]] +name = "filelock" +version = "3.32.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/df/31098c5aeb4d966b553641472bd55fcf5fdfac953549894b8a765ba44e91/filelock-3.32.7-py3-none-any.whl", hash = "sha256:65ff0d0190ea42038b32bda4b77834fb05be2cad4c5b9b01aa4dfb3614536e52", size = 100157, upload-time = "2026-09-16T00:24:19.543Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz", hash = "sha256:cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63", size = 154133, upload-time = "2026-10-14T18:50:30.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl", hash = "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0", size = 74405, upload-time = "2026-10-14T18:50:29.116Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/e6/d0abbb256b44e6a05748c6942511ff67db8dbb068de0c35b24bf1b395b09/platformdirs-4.13.5.tar.gz", hash = "sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0", size = 62362, upload-time = "2026-10-19T01:02:11.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/8a/1d8cb3842909dc193a73835adedf412e32e18f8ffd59f43983949622fe6f/platformdirs-4.13.5-py3-none-any.whl", hash = "sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e", size = 32779, upload-time = "2026-10-19T01:02:10.17Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pyarrow" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +] + +[[package]] +name = "pycparser" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.17.2" @@ -56,6 +671,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, ] +[[package]] +name = "s3transfer" +version = "0.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowflake-connector-python" +version = "4.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "cryptography" }, + { name = "filelock" }, + { name = "idna" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pyjwt" }, + { name = "pyopenssl" }, + { name = "pytz" }, + { name = "requests" }, + { name = "sortedcontainers" }, + { name = "tomlkit" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/73/e6e45343dd133207a6f710a88e0675ae69f8c12c23efe7f192448bbdace9/snowflake_connector_python-4.7.2.tar.gz", hash = "sha256:c261b73b8dbe3f3d5b9bfdc19196f38e3eade42cb2f24322b7a2bb60fef76534", size = 954059, upload-time = "2026-08-07T13:59:54.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/17/a3f4c38a4535b137139a353e60874c83e22eea535b37ce43651191ac5d1f/snowflake_connector_python-4.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4d19ab07f57ec14d074086d7a4eadb51b32e75b37c91e9893ad3702ecd2e3505", size = 1181036, upload-time = "2026-08-07T14:00:02.642Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/c9446d830f33961ea7c86b4b5343a0fefda3882a31f577d74025ee8d26f5/snowflake_connector_python-4.7.2-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:1d94f27d67520e6729709114ee00916d89defa909d4cd456d53fe6106dc14df1", size = 1193269, upload-time = "2026-08-07T14:00:04.337Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/0cee4d1d6ddd268c2443b2c3e8e2a98139198746525bcf73a1da876d940e/snowflake_connector_python-4.7.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:243c4e2621a58a34909a7863bbffaa886870c9fe19e88804a608660fb775f025", size = 2915086, upload-time = "2026-08-07T13:59:45.446Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/a52387d5abd5988498deeee502c3889b576f21e486f128115ce548006517/snowflake_connector_python-4.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:24e7a787cae7745756cb25c829e4fef1b828d2872dd70e0e31ff37a9323a62c7", size = 2947242, upload-time = "2026-08-07T13:59:46.904Z" }, + { url = "https://files.pythonhosted.org/packages/e5/29/4cc9353e21ec19570ac2916ed15a237d9cd42675ad70d7b9aa3dd2be6ea7/snowflake_connector_python-4.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:d7b1eabecdeaff6b0df8f583dc1b7a8bbd92e996e29212c324013e4ad7662c75", size = 5419590, upload-time = "2026-08-07T14:00:16.379Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/12defd8c0022ded5a11d3e2204ee52c0484654a869db3d2bf9027c7d2384/snowflake_connector_python-4.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9cb47d1d4881fe7338af91a38029c68926fab016b58fb6963954492257e2a1d9", size = 1179960, upload-time = "2026-08-07T14:00:05.802Z" }, + { url = "https://files.pythonhosted.org/packages/9f/70/b5ccddf8b3689455a3652f71eba16b35753dd90dc6936a1f0905c41a4db9/snowflake_connector_python-4.7.2-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:06281d6f66e857b4d8362fc0738600dafd635da1c1cf91537f0cd57f658ec1ec", size = 1192786, upload-time = "2026-08-07T14:00:07.207Z" }, + { url = "https://files.pythonhosted.org/packages/3a/0f/839bcabc5231daf009db958b11838fd991f68f7de137bb3918f8e5368de4/snowflake_connector_python-4.7.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0771229b11862b0eac60d8a771c66af79b5c3248b5200ad2898217ab4c7d385b", size = 2886123, upload-time = "2026-08-07T13:59:48.314Z" }, + { url = "https://files.pythonhosted.org/packages/e6/77/85754d5a5f3ba7f11a3f0aa569b2edd2429aa96f2342486b1da4530329c6/snowflake_connector_python-4.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb1e56efaaf258bb8a28968bbebea82488a02a2ffda354e65ea7921935289a19", size = 2918812, upload-time = "2026-08-07T13:59:49.747Z" }, + { url = "https://files.pythonhosted.org/packages/e8/01/9015c0918f4146281c2d36f72069a9ec538b4165bddb2b5cd3bc9ff4f24c/snowflake_connector_python-4.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:17f9cee8985b726afe027920b2842c41b0c52280747d37efa1643c3f77fd5505", size = 5419347, upload-time = "2026-08-07T14:00:18.013Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9e/d768063750fd8e155311414580b79f35552e95b72f1bcbb702f9e577a5bc/snowflake_connector_python-4.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9b5a3860bf4350786c35ffaac0ba60f7b13ef72d6296d23b28e356be1fe938c9", size = 1180582, upload-time = "2026-08-07T14:00:08.808Z" }, + { url = "https://files.pythonhosted.org/packages/01/40/fcff393b5b6de392931e43c5ca17298e59aaf8fd89b34189748af8166bc9/snowflake_connector_python-4.7.2-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:89e19d070ce884a370a2fa6273fcba8c8e1d7fe7c57861ba15ef92be603dfe19", size = 1192907, upload-time = "2026-08-07T14:00:10.44Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/b2e0676e2637d612e45e711eb3cbd17be3ffa60ea64ff5d7fea8f9ef6261/snowflake_connector_python-4.7.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ebae2ff7311e9f383ce805d790b6b85cb9a30fe81b1b5dc56d96fe92cd6b5df", size = 2885786, upload-time = "2026-08-07T13:59:51.11Z" }, + { url = "https://files.pythonhosted.org/packages/ec/cb/a8918acef070f833800c1d3c8221610efffe2c8f0d087e69c81d01c33d39/snowflake_connector_python-4.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3751daec1a0f05470d6419ddf068f11e3b4d464d683ee4d0b8749a9a0c0605b8", size = 2914542, upload-time = "2026-08-07T13:59:52.624Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0f/e1556f2501b538875c7d3f656f91b5c1ea66d6b88c33ec611b835c4933c5/snowflake_connector_python-4.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:b06b910ff3965bad9c389e1b423837503c5e29f0bc51a9cec7ba8a3df92e6ca8", size = 5478102, upload-time = "2026-08-07T14:00:19.752Z" }, +] + +[package.optional-dependencies] +secure-local-storage = [ + { name = "keyring" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlquack-record" +version = "0.1.0" +source = { editable = "recorder" } +dependencies = [ + { name = "pyarrow" }, + { name = "snowflake-connector-python", extra = ["secure-local-storage"] }, +] + +[package.metadata] +requires-dist = [ + { name = "pyarrow", specifier = "==24.0.0" }, + { name = "snowflake-connector-python", extras = ["secure-local-storage"], specifier = "==4.7.2" }, +] + [[package]] name = "sqlquack-workspace" version = "0.0.0" @@ -64,7 +784,9 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ { name = "fensu" }, + { name = "pytest" }, { name = "ruff" }, + { name = "sqlquack-record" }, { name = "ty" }, ] @@ -73,10 +795,21 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "fensu", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.4" }, { name = "ruff", specifier = ">=0.12.0" }, + { name = "sqlquack-record", editable = "recorder" }, { name = "ty", specifier = ">=0.0.1a19" }, ] +[[package]] +name = "tomlkit" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz", hash = "sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6", size = 190522, upload-time = "2026-10-16T04:07:08.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl", hash = "sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437", size = 52225, upload-time = "2026-10-16T04:07:09.216Z" }, +] + [[package]] name = "ty" version = "0.0.91" @@ -101,3 +834,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/63/27c8051a72328ee81f47dace5e2c63e7db43bcae935db25a9dbd4d0e59b2/ty-0.0.91-py3-none-win_amd64.whl", hash = "sha256:5ac9eaf938e7fba4dd6c3358f852e6eb3127aa7bde0a76d9807c41f60ad73a29", size = 13759484, upload-time = "2026-10-22T18:24:48.111Z" }, { url = "https://files.pythonhosted.org/packages/aa/a9/1f6c3694b7b33be4ae9a272f71a99a7dd27ec020c32a22049e9c0e79f576/ty-0.0.91-py3-none-win_arm64.whl", hash = "sha256:35820957639db1fb8c643273670967192c308038836005bdf3bfef111aff64a0", size = 13318343, upload-time = "2026-10-22T18:24:50.116Z" }, ] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] From 23e99800a43d44f1354e88d026518b48dc5efcc1 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:27:12 +0100 Subject: [PATCH 04/11] refactor: evidence crates follow the Rust structure rules --- .../src/results/_helpers/mod.rs | 2 +- .../src/results/_helpers/reading.rs | 2 +- .../_helpers/{types.rs => type_names.rs} | 0 .../src/verify/tests/helpers.rs | 155 ++++++++++-------- .../src/verify/tests/test_round_trip.rs | 48 +++--- .../src/verify/tests/test_types.rs | 25 ++- .../src/cli/_helpers/audit_command.rs | 132 +++++++-------- crates/sqlquack/src/cli/_helpers/evidence.rs | 30 ++-- .../src/cli/_helpers/pending_command.rs | 32 ++-- .../src/cli/_helpers/verify_command.rs | 67 ++++---- crates/sqlquack/src/cli/main/run_cli.rs | 7 +- crates/sqlquack/tests/cli.rs | 6 + .../tests/{evidence => cli}/helpers.rs | 23 +-- .../test_evidence_commands.rs | 2 +- .../tests/{evidence => cli}/test_types.rs | 0 crates/sqlquack/tests/evidence.rs | 6 - 16 files changed, 276 insertions(+), 261 deletions(-) rename crates/sqlquack-evidence/src/results/_helpers/{types.rs => type_names.rs} (100%) create mode 100644 crates/sqlquack/tests/cli.rs rename crates/sqlquack/tests/{evidence => cli}/helpers.rs (66%) rename crates/sqlquack/tests/{evidence => cli}/test_evidence_commands.rs (97%) rename crates/sqlquack/tests/{evidence => cli}/test_types.rs (100%) delete mode 100644 crates/sqlquack/tests/evidence.rs diff --git a/crates/sqlquack-evidence/src/results/_helpers/mod.rs b/crates/sqlquack-evidence/src/results/_helpers/mod.rs index a6fb24b74..5687595b0 100644 --- a/crates/sqlquack-evidence/src/results/_helpers/mod.rs +++ b/crates/sqlquack-evidence/src/results/_helpers/mod.rs @@ -1,2 +1,2 @@ pub(crate) mod reading; -pub(crate) mod types; +pub(crate) mod type_names; diff --git a/crates/sqlquack-evidence/src/results/_helpers/reading.rs b/crates/sqlquack-evidence/src/results/_helpers/reading.rs index 0a997bc7b..412ce854f 100644 --- a/crates/sqlquack-evidence/src/results/_helpers/reading.rs +++ b/crates/sqlquack-evidence/src/results/_helpers/reading.rs @@ -11,7 +11,7 @@ use sqlquack_compare::values::models::ResultSet; use crate::constants::EVIDENCE_FORMAT; use crate::errors::EvidenceError; -use crate::results::_helpers::types::snowflake_type; +use crate::results::_helpers::type_names::snowflake_type; use crate::results::models::ResultMetadata; pub(crate) fn sha256_hex(bytes: &[u8]) -> String { diff --git a/crates/sqlquack-evidence/src/results/_helpers/types.rs b/crates/sqlquack-evidence/src/results/_helpers/type_names.rs similarity index 100% rename from crates/sqlquack-evidence/src/results/_helpers/types.rs rename to crates/sqlquack-evidence/src/results/_helpers/type_names.rs diff --git a/crates/sqlquack-evidence/src/verify/tests/helpers.rs b/crates/sqlquack-evidence/src/verify/tests/helpers.rs index 97e1b6270..3548d456c 100644 --- a/crates/sqlquack-evidence/src/verify/tests/helpers.rs +++ b/crates/sqlquack-evidence/src/verify/tests/helpers.rs @@ -17,12 +17,14 @@ use crate::replay::models::ReplaySettings; use crate::results::_helpers::reading::sha256_hex; use crate::results::models::{RecordedOutcome, ResultMetadata, WarehouseColumn}; use crate::verify::main::verify_cases::verify_cases; -use crate::verify::models::Verdict; -use crate::verify::tests::test_types::{Tampering, UnchangedSqlForTests}; +use crate::verify::tests::test_types::{RoundTrip, Tampering, UnchangedSqlForTests}; /// Marks every stand-in result: produced by DuckDB in a test, never warehouse evidence. const STAND_IN_RECORDER: &str = "test fixture: DuckDB stand-in, not evidence"; +/// The case whose stand-in result the tampering functions alter. +const TAMPERED_CASE: &str = "numeric/addition.toml#decimal_addition"; + const POLICY: &str = r#" schema_version = 1 @@ -85,73 +87,99 @@ order = "bag" tags = [] "#; +/// How DuckDB result types of the fixture cases are described as warehouse columns. +fn fixture_column_types() -> [(DataType, (&'static str, i64, i64)); 3] { + [ + (DataType::Int32, ("FIXED", 10, 0)), + (DataType::Decimal128(10, 2), ("FIXED", 10, 2)), + (DataType::Decimal128(11, 2), ("FIXED", 11, 2)), + ] +} + fn warehouse_column(name: &str, data_type: &DataType) -> WarehouseColumn { - let (type_name, precision, scale, internal_size) = match data_type { - DataType::Decimal128(precision, scale) => ( - "FIXED", - Some(i64::from(*precision)), - Some(i64::from(*scale)), - None, - ), - DataType::Int32 => ("FIXED", Some(10), Some(0), None), - _ => ("TEXT", None, None, Some(16_777_216)), - }; + let (_, (type_name, precision, scale)) = fixture_column_types() + .into_iter() + .find(|(fixture_type, _)| fixture_type == data_type) + .expect("every fixture result type is described"); WarehouseColumn { name: name.to_ascii_uppercase(), type_code: 0, type_name: type_name.to_owned(), - precision, - scale, - internal_size, + precision: Some(precision), + scale: Some(scale), + internal_size: None, nullable: Some(true), } } -fn changed_value(rows: &ResultSet) -> Vec { - let batch = &rows.batches[0]; - let last = batch.num_columns() - 1; - let changed: ArrayRef = match batch.column(last).data_type() { - DataType::Decimal128(precision, scale) => Arc::new( - Decimal128Array::from(vec![Some(1); batch.num_rows()]) - .with_precision_and_scale(*precision, *scale) - .expect("valid decimal"), - ), - _ => batch.column(last).clone(), - }; - let mut columns: Vec = batch.columns().to_vec(); - columns[last] = changed; - vec![RecordBatch::try_new(batch.schema(), columns).expect("same schema")] +fn read_arrow(path: &Path) -> ResultSet { + let reader = + arrow::ipc::reader::FileReader::try_new(File::open(path).expect("arrow file opened"), None) + .expect("arrow file reads"); + let schema = reader.schema(); + let batches = reader.collect::, _>>().expect("batches read"); + ResultSet { schema, batches } } -fn write_arrow(path: &Path, rows: &ResultSet, batches: &[RecordBatch]) -> Vec { +fn write_arrow(path: &Path, rows: &ResultSet) -> Vec { let file = File::create(path).expect("arrow file created"); let mut writer = FileWriter::try_new(file, &rows.schema).expect("arrow writer"); - for batch in batches { + for batch in &rows.batches { writer.write(batch).expect("batch written"); } writer.finish().expect("arrow file finished"); std::fs::read(path).expect("arrow file read back") } -fn record_stand_in(pending: &PendingCase, results: &Path, tampering: Tampering) { +/// Sets every value of the last column to 0.01 and rewrites the Arrow file; returns its bytes. +fn rewrite_last_column(results: &Path, hash: &str) -> Vec { + let path = results.join(format!("{hash}.arrow")); + let rows = read_arrow(&path); + let batch = &rows.batches[0]; + let last = batch.num_columns() - 1; + let changed: ArrayRef = Arc::new( + Decimal128Array::from(vec![Some(1); batch.num_rows()]) + .with_data_type(batch.column(last).data_type().clone()), + ); + let mut columns: Vec = batch.columns().to_vec(); + columns[last] = changed; + let changed_rows = ResultSet { + schema: rows.schema.clone(), + batches: vec![RecordBatch::try_new(batch.schema(), columns).expect("same schema")], + }; + write_arrow(&path, &changed_rows) +} + +/// Leaves the result as recorded. +pub(crate) fn untouched(_: &Path, _: &str) {} + +/// Changes a value and updates the metadata's Arrow hash to match. +pub(crate) fn value_changed(results: &Path, hash: &str) { + let bytes = rewrite_last_column(results, hash); + let metadata_path = results.join(format!("{hash}.json")); + let mut metadata: ResultMetadata = + serde_json::from_str(&std::fs::read_to_string(&metadata_path).expect("metadata read")) + .expect("metadata parses"); + metadata.arrow_sha256 = Some(sha256_hex(&bytes)); + std::fs::write( + &metadata_path, + serde_json::to_string_pretty(&metadata).expect("metadata serialises"), + ) + .expect("metadata written"); +} + +/// Changes a value without touching the metadata. +pub(crate) fn arrow_edited(results: &Path, hash: &str) { + rewrite_last_column(results, hash); +} + +fn record_stand_in(pending: &PendingCase, results: &Path) { let settings = ReplaySettings { time_zone: "UTC".to_owned(), null_order: "nulls_last_on_asc_first_on_desc".to_owned(), }; let rows = replay_on_duckdb(&pending.source_sql, &settings).expect("DuckDB runs the case"); - let arrow_path = results.join(format!("{}.arrow", pending.case_hash)); - let recorded_bytes = write_arrow(&arrow_path, &rows, &rows.batches); - let tampered = [Tampering::ValueChanged, Tampering::ArrowEdited].contains(&tampering); - let final_bytes = if tampered { - write_arrow(&arrow_path, &rows, &changed_value(&rows)) - } else { - recorded_bytes.clone() - }; - let hashed_bytes = if tampering == Tampering::ArrowEdited { - recorded_bytes - } else { - final_bytes - }; + let bytes = write_arrow(&results.join(format!("{}.arrow", pending.case_hash)), &rows); let metadata = ResultMetadata { format: 1, case_hash: pending.case_hash.clone(), @@ -175,7 +203,7 @@ fn record_stand_in(pending: &PendingCase, results: &Path, tampering: Tampering) .map(RecordBatch::num_rows) .sum::() as u64, ), - arrow_sha256: Some(sha256_hex(&hashed_bytes)), + arrow_sha256: Some(sha256_hex(&bytes)), error: None, }; std::fs::write( @@ -185,31 +213,17 @@ fn record_stand_in(pending: &PendingCase, results: &Path, tampering: Tampering) .expect("metadata written"); } -pub(crate) struct RoundTrip { - pub(crate) pending_before: usize, - pub(crate) pending_after: usize, - pub(crate) verdicts: Vec<&'static str>, -} - -fn verdict_name(verdict: &Verdict) -> &'static str { - match verdict { - Verdict::Pending => "pending", - Verdict::Refused { .. } => "refused", - Verdict::Passed => "passed", - Verdict::Failed { .. } => "failed", - } -} - +/// Writes pending files, records DuckDB stand-ins for them, tampers with one, and verifies. pub(crate) fn round_trip(tampering: Tampering) -> RoundTrip { let directory = tempfile::tempdir().expect("temporary directory"); let root = directory.path(); let cases_dir = root.join("cases").join("numeric"); std::fs::create_dir_all(&cases_dir).expect("cases directory"); std::fs::write(cases_dir.join("addition.toml"), CASE_FILE).expect("case file"); + std::fs::write(root.join("sqlquack.toml"), POLICY).expect("policy written"); let results = root.join("results"); let pending_dir = root.join("pending"); std::fs::create_dir_all(&results).expect("results directory"); - std::fs::write(root.join("sqlquack.toml"), POLICY).expect("policy written"); let policy = load_policy(&root.join("sqlquack.toml")).expect("the test policy is valid"); let cases = load_cases(&root.join("cases"), &policy).expect("cases load"); let before = write_pending(&cases, &results, &pending_dir, "v2.0.0").expect("pending written"); @@ -222,14 +236,10 @@ pub(crate) fn round_trip(tampering: Tampering) -> RoundTrip { let case: PendingCase = serde_json::from_str(&text).expect("pending parses"); pending.insert(case.case_reference.clone(), case); } - for (reference, case) in &pending { - let applied = if reference.ends_with("#decimal_addition") { - tampering - } else { - Tampering::None - }; - record_stand_in(case, &results, applied); + for case in pending.values() { + record_stand_in(case, &results); } + tampering(&results, &pending[TAMPERED_CASE].case_hash); let after = write_pending(&cases, &results, &pending_dir, "v2.0.0").expect("pending rewritten"); let report = verify_cases(&cases, &results, &policy, &UnchangedSqlForTests).expect("verify runs"); @@ -239,7 +249,12 @@ pub(crate) fn round_trip(tampering: Tampering) -> RoundTrip { verdicts: report .cases .iter() - .map(|case| verdict_name(&case.verdict)) + .map(|case| { + serde_json::to_value(&case.verdict).expect("verdict serialises")["verdict"] + .as_str() + .unwrap_or_default() + .to_owned() + }) .collect(), } } diff --git a/crates/sqlquack-evidence/src/verify/tests/test_round_trip.rs b/crates/sqlquack-evidence/src/verify/tests/test_round_trip.rs index 0bf95e2e6..0268c0177 100644 --- a/crates/sqlquack-evidence/src/verify/tests/test_round_trip.rs +++ b/crates/sqlquack-evidence/src/verify/tests/test_round_trip.rs @@ -1,46 +1,42 @@ -use crate::verify::tests::helpers::round_trip; -use crate::verify::tests::test_types::{RoundTripTestCase, Tampering}; +use crate::verify::tests::helpers::{arrow_edited, round_trip, untouched, value_changed}; +use crate::verify::tests::test_types::{RoundTrip, RoundTripTestCase}; #[test] fn given_duckdb_stand_in_results_when_verifying_then_only_untampered_results_pass() { let test_cases = [ RoundTripTestCase { description: "pending, stand-in recording and replay agree", - tampering: Tampering::None, - expected_pending_before: 2, - expected_pending_after: 0, - expected_verdicts: vec!["passed", "passed"], + tampering: untouched, + expected_round_trip: RoundTrip { + pending_before: 2, + pending_after: 0, + verdicts: vec!["passed".to_owned(), "passed".to_owned()], + }, }, RoundTripTestCase { description: "a changed value with a consistent Arrow hash fails the comparison", - tampering: Tampering::ValueChanged, - expected_pending_before: 2, - expected_pending_after: 0, - expected_verdicts: vec!["failed", "passed"], + tampering: value_changed, + expected_round_trip: RoundTrip { + pending_before: 2, + pending_after: 0, + verdicts: vec!["failed".to_owned(), "passed".to_owned()], + }, }, RoundTripTestCase { description: "an Arrow file edited after recording fails the integrity check", - tampering: Tampering::ArrowEdited, - expected_pending_before: 2, - expected_pending_after: 0, - expected_verdicts: vec!["failed", "passed"], + tampering: arrow_edited, + expected_round_trip: RoundTrip { + pending_before: 2, + pending_after: 0, + verdicts: vec!["failed".to_owned(), "passed".to_owned()], + }, }, ]; for test_case in test_cases { - let observed = round_trip(test_case.tampering); assert_eq!( - observed.pending_before, test_case.expected_pending_before, - "{}", - test_case.description - ); - assert_eq!( - observed.pending_after, test_case.expected_pending_after, - "{}", - test_case.description - ); - assert_eq!( - observed.verdicts, test_case.expected_verdicts, + round_trip(test_case.tampering), + test_case.expected_round_trip, "{}", test_case.description ); diff --git a/crates/sqlquack-evidence/src/verify/tests/test_types.rs b/crates/sqlquack-evidence/src/verify/tests/test_types.rs index 865892065..2ba22bf78 100644 --- a/crates/sqlquack-evidence/src/verify/tests/test_types.rs +++ b/crates/sqlquack-evidence/src/verify/tests/test_types.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use crate::cases::models::LoadedCase; use crate::verify::models::Conversion; use crate::verify::types::CaseConverter; @@ -11,21 +13,18 @@ impl CaseConverter for UnchangedSqlForTests { } } -/// How a stand-in result is tampered with before verification. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Tampering { - /// Left as recorded. - None, - /// A value changed and the metadata's Arrow hash updated to match. - ValueChanged, - /// A value changed without updating the metadata. - ArrowEdited, -} +/// Alters the stand-in result named by a hash in a results directory. +pub(crate) type Tampering = fn(&Path, &str); pub(crate) struct RoundTripTestCase { pub(crate) description: &'static str, pub(crate) tampering: Tampering, - pub(crate) expected_pending_before: usize, - pub(crate) expected_pending_after: usize, - pub(crate) expected_verdicts: Vec<&'static str>, + pub(crate) expected_round_trip: RoundTrip, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RoundTrip { + pub(crate) pending_before: usize, + pub(crate) pending_after: usize, + pub(crate) verdicts: Vec, } diff --git a/crates/sqlquack/src/cli/_helpers/audit_command.rs b/crates/sqlquack/src/cli/_helpers/audit_command.rs index 78f0416cf..b0c9777ab 100644 --- a/crates/sqlquack/src/cli/_helpers/audit_command.rs +++ b/crates/sqlquack/src/cli/_helpers/audit_command.rs @@ -1,4 +1,4 @@ -//! `sqlquack audit`. +//! `sqlquack audit`: `--sample` writes pending files for re-recording; `--compare` checks them. use std::io::Write; use std::process::ExitCode; @@ -6,66 +6,37 @@ use std::time::{SystemTime, UNIX_EPOCH}; use sqlquack_evidence::audit::main::compare_audit::compare_audit; use sqlquack_evidence::audit::main::sample_cases::sample_cases; -use sqlquack_evidence::audit::models::AuditOutcome; +use sqlquack_evidence::audit::models::{AuditEntry, AuditOutcome}; use sqlquack_evidence::cases::models::LoadedCase; use sqlquack_evidence::pending::main::write_pending::write_pending; use sqlquack_evidence::replay::main::duckdb_version::duckdb_version; -use crate::cli::_helpers::evidence::{Evidence, load_evidence}; +use crate::cli::_helpers::evidence::{load_evidence, parse_percent}; use crate::cli::models::AuditArguments; -pub(crate) fn audit_command( - arguments: &AuditArguments, - stdout: &mut dyn Write, - stderr: &mut dyn Write, -) -> ExitCode { - let Some(evidence) = load_evidence("audit", &arguments.paths, stderr) else { - return ExitCode::FAILURE; - }; - match &arguments.sample { - Some(sample) => sample_command(arguments, sample, &evidence, stderr), - None => compare_command(arguments, &evidence, stdout, stderr), - } -} - -fn sample_command( +pub(crate) fn audit_sample_command( arguments: &AuditArguments, sample: &str, - evidence: &Evidence, stderr: &mut dyn Write, ) -> ExitCode { - let Some(percent) = sample - .trim() - .strip_suffix('%') - .and_then(|number| number.trim().parse::().ok()) - .filter(|percent| (0.0..=100.0).contains(percent)) - else { - let _ = writeln!( - stderr, - "sqlquack audit: --sample needs a percentage such as 10%" - ); - return ExitCode::from(2); + let percent = match parse_percent(sample) { + Ok(percent) => percent, + Err(error) => { + let _ = writeln!(stderr, "sqlquack audit: {error}"); + return ExitCode::from(2); + } }; let seed = arguments.seed.unwrap_or_else(|| { SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |elapsed| elapsed.as_secs()) }); - let chosen: Vec = - sample_cases(&evidence.cases, &arguments.paths.results, percent, seed) - .into_iter() - .cloned() - .collect(); - let written = duckdb_version().and_then(|version| { - write_pending(&chosen, &arguments.audit_results, &arguments.out, &version) - }); - match written { - Ok(summary) => { + let _ = writeln!(stderr, "sqlquack audit: sampling recorded cases…"); + match sample_pending(arguments, percent, seed) { + Ok((sampled, written)) => { let _ = writeln!( stderr, - "sqlquack audit: done, sampled {} recorded cases with seed {seed}; {} pending case files in {}", - chosen.len(), - summary.written, + "sqlquack audit: done, sampled {sampled} recorded cases with seed {seed}; {written} pending case files in {}", arguments.out.display() ); ExitCode::SUCCESS @@ -77,17 +48,16 @@ fn sample_command( } } -fn compare_command( +pub(crate) fn audit_compare_command( arguments: &AuditArguments, - evidence: &Evidence, stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> ExitCode { - let entries = match compare_audit( - &evidence.cases, - &arguments.paths.results, - &arguments.audit_results, - ) { + let _ = writeln!( + stderr, + "sqlquack audit: comparing re-recorded results with stored ones…" + ); + let entries = match compare(arguments) { Ok(entries) => entries, Err(error) => { let _ = writeln!(stderr, "sqlquack audit: failed: {error}"); @@ -97,22 +67,7 @@ fn compare_command( let rendered = if arguments.json { serde_json::to_string_pretty(&entries).unwrap_or_default() + "\n" } else { - entries - .iter() - .map(|entry| { - let outcome = match &entry.outcome { - AuditOutcome::Matched => "matched".to_owned(), - AuditOutcome::Differed { detail } => format!("DIFFERED: {detail}"), - AuditOutcome::Invalid { detail } => format!("INVALID: {detail}"), - }; - format!( - "{} {outcome} (warehouse {} then {})\n", - entry.reference.as_deref().unwrap_or(&entry.hash), - entry.stored_warehouse_version.as_deref().unwrap_or("?"), - entry.fresh_warehouse_version.as_deref().unwrap_or("?") - ) - }) - .collect() + render(&entries) }; let _ = stdout.write_all(rendered.as_bytes()); let problems = entries @@ -131,3 +86,48 @@ fn compare_command( ExitCode::FAILURE } } + +fn sample_pending( + arguments: &AuditArguments, + percent: f64, + seed: u64, +) -> Result<(usize, usize), String> { + let evidence = load_evidence(&arguments.paths)?; + let chosen: Vec = + sample_cases(&evidence.cases, &arguments.paths.results, percent, seed) + .into_iter() + .cloned() + .collect(); + let version = duckdb_version().map_err(|error| error.to_string())?; + let summary = write_pending(&chosen, &arguments.audit_results, &arguments.out, &version) + .map_err(|error| error.to_string())?; + Ok((chosen.len(), summary.written)) +} + +fn compare(arguments: &AuditArguments) -> Result, String> { + let evidence = load_evidence(&arguments.paths)?; + compare_audit( + &evidence.cases, + &arguments.paths.results, + &arguments.audit_results, + ) + .map_err(|error| error.to_string()) +} + +fn render(entries: &[AuditEntry]) -> String { + let mut rendered = String::new(); + for entry in entries { + let outcome = match &entry.outcome { + AuditOutcome::Matched => "matched".to_owned(), + AuditOutcome::Differed { detail } => format!("DIFFERED: {detail}"), + AuditOutcome::Invalid { detail } => format!("INVALID: {detail}"), + }; + rendered.push_str(&format!( + "{} {outcome} (warehouse {} then {})\n", + entry.reference.as_deref().unwrap_or(&entry.hash), + entry.stored_warehouse_version.as_deref().unwrap_or("?"), + entry.fresh_warehouse_version.as_deref().unwrap_or("?") + )); + } + rendered +} diff --git a/crates/sqlquack/src/cli/_helpers/evidence.rs b/crates/sqlquack/src/cli/_helpers/evidence.rs index 2ae73f41c..3e51adfc3 100644 --- a/crates/sqlquack/src/cli/_helpers/evidence.rs +++ b/crates/sqlquack/src/cli/_helpers/evidence.rs @@ -1,7 +1,5 @@ //! Loading the policy and cases for evidence commands. -use std::io::Write; - use sqlquack_evidence::cases::main::load_cases::load_cases; use sqlquack_evidence::cases::models::LoadedCase; use sqlquack_policy::policy::main::load_policy::load_policy; @@ -14,18 +12,18 @@ pub(crate) struct Evidence { pub(crate) cases: Vec, } -pub(crate) fn load_evidence( - command: &str, - paths: &EvidencePaths, - stderr: &mut dyn Write, -) -> Option { - let _ = writeln!(stderr, "sqlquack {command}: loading the policy and cases…"); - let policy = load_policy(&paths.policy) - .map_err(|error| writeln!(stderr, "sqlquack {command}: failed: {error}")) - .ok()?; - let cases = load_cases(&paths.cases, &policy) - .map_err(|error| writeln!(stderr, "sqlquack {command}: failed: {error}")) - .ok()?; - let _ = writeln!(stderr, "sqlquack {command}: loaded {} cases", cases.len()); - Some(Evidence { policy, cases }) +pub(crate) fn load_evidence(paths: &EvidencePaths) -> Result { + let policy = load_policy(&paths.policy).map_err(|error| error.to_string())?; + let cases = load_cases(&paths.cases, &policy).map_err(|error| error.to_string())?; + Ok(Evidence { policy, cases }) +} + +/// Parses `10%` and similar into a percentage between 0 and 100. +pub(crate) fn parse_percent(sample: &str) -> Result { + let invalid = || format!("--sample needs a percentage such as 10%, not `{sample}`"); + let number = sample.trim().strip_suffix('%').ok_or_else(invalid)?; + match number.trim().parse::() { + Ok(percent) if (0.0..=100.0).contains(&percent) => Ok(percent), + Ok(_) | Err(_) => Err(invalid()), + } } diff --git a/crates/sqlquack/src/cli/_helpers/pending_command.rs b/crates/sqlquack/src/cli/_helpers/pending_command.rs index b27fb572c..48ffb45d2 100644 --- a/crates/sqlquack/src/cli/_helpers/pending_command.rs +++ b/crates/sqlquack/src/cli/_helpers/pending_command.rs @@ -4,28 +4,19 @@ use std::io::Write; use std::process::ExitCode; use sqlquack_evidence::pending::main::write_pending::write_pending; +use sqlquack_evidence::pending::models::PendingSummary; use sqlquack_evidence::replay::main::duckdb_version::duckdb_version; use crate::cli::_helpers::evidence::load_evidence; use crate::cli::models::PendingArguments; pub(crate) fn pending_command(arguments: &PendingArguments, stderr: &mut dyn Write) -> ExitCode { - let Some(evidence) = load_evidence("pending", &arguments.paths, stderr) else { - return ExitCode::FAILURE; - }; - let written = duckdb_version().and_then(|version| { - write_pending( - &evidence.cases, - &arguments.paths.results, - &arguments.out, - &version, - ) - }); - match written { - Ok(summary) => { + let _ = writeln!(stderr, "sqlquack pending: loading the policy and cases…"); + match pending(arguments) { + Ok((cases, summary)) => { let _ = writeln!( stderr, - "sqlquack pending: done, {} pending case files in {} ({} cases already recorded, {} stale files removed)", + "sqlquack pending: done, {} pending case files in {} ({cases} cases, {} already recorded, {} stale files removed)", summary.written, arguments.out.display(), summary.recorded, @@ -39,3 +30,16 @@ pub(crate) fn pending_command(arguments: &PendingArguments, stderr: &mut dyn Wri } } } + +fn pending(arguments: &PendingArguments) -> Result<(usize, PendingSummary), String> { + let evidence = load_evidence(&arguments.paths)?; + let version = duckdb_version().map_err(|error| error.to_string())?; + let summary = write_pending( + &evidence.cases, + &arguments.paths.results, + &arguments.out, + &version, + ) + .map_err(|error| error.to_string())?; + Ok((evidence.cases.len(), summary)) +} diff --git a/crates/sqlquack/src/cli/_helpers/verify_command.rs b/crates/sqlquack/src/cli/_helpers/verify_command.rs index 57c3bee2c..2cdf29338 100644 --- a/crates/sqlquack/src/cli/_helpers/verify_command.rs +++ b/crates/sqlquack/src/cli/_helpers/verify_command.rs @@ -15,19 +15,11 @@ pub(crate) fn verify_command( stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> ExitCode { - let Some(evidence) = load_evidence("verify", &arguments.paths, stderr) else { - return ExitCode::FAILURE; - }; let _ = writeln!( stderr, - "sqlquack verify: replaying recorded cases on DuckDB…" + "sqlquack verify: loading cases and replaying recorded ones on DuckDB…" ); - let report = match verify_cases( - &evidence.cases, - &arguments.paths.results, - &evidence.policy, - &PipelineConverter, - ) { + let report = match verify(arguments) { Ok(report) => report, Err(error) => { let _ = writeln!(stderr, "sqlquack verify: failed: {error}"); @@ -60,6 +52,17 @@ pub(crate) fn verify_command( } } +fn verify(arguments: &VerifyArguments) -> Result { + let evidence = load_evidence(&arguments.paths)?; + verify_cases( + &evidence.cases, + &arguments.paths.results, + &evidence.policy, + &PipelineConverter, + ) + .map_err(|error| error.to_string()) +} + fn count(report: &VerifyReport, predicate: impl Fn(&Verdict) -> bool) -> usize { report .cases @@ -69,30 +72,24 @@ fn count(report: &VerifyReport, predicate: impl Fn(&Verdict) -> bool) -> usize { } fn render(report: &VerifyReport) -> String { - let mut lines: Vec = report - .cases - .iter() - .map(|case| { - let detail = match &case.verdict { - Verdict::Pending => "pending: no recorded result yet".to_owned(), - Verdict::Passed => "passed".to_owned(), - Verdict::Refused { reasons } => { - format!( - "refused: {} reasons, first: {}", - reasons.len(), - reasons.first().map_or("", String::as_str) - ) - } - Verdict::Failed { reason } => format!("FAILED: {reason}"), - }; - format!("{} {detail}", case.reference) - }) - .collect(); - lines.extend( - report - .unreferenced_results - .iter() - .map(|hash| format!("note: result {hash} is no longer named by any case")), - ); + let mut lines: Vec = Vec::new(); + for case in &report.cases { + let detail = match &case.verdict { + Verdict::Pending => "pending: no recorded result yet".to_owned(), + Verdict::Passed => "passed".to_owned(), + Verdict::Refused { reasons } => format!( + "refused: {} reasons, first: {}", + reasons.len(), + reasons.first().map_or("", String::as_str) + ), + Verdict::Failed { reason } => format!("FAILED: {reason}"), + }; + lines.push(format!("{} {detail}", case.reference)); + } + for hash in &report.unreferenced_results { + lines.push(format!( + "note: result {hash} is no longer named by any case" + )); + } lines.join("\n") + "\n" } diff --git a/crates/sqlquack/src/cli/main/run_cli.rs b/crates/sqlquack/src/cli/main/run_cli.rs index fd8039708..247f3275f 100644 --- a/crates/sqlquack/src/cli/main/run_cli.rs +++ b/crates/sqlquack/src/cli/main/run_cli.rs @@ -4,7 +4,7 @@ use std::process::ExitCode; use clap::Parser; -use crate::cli::_helpers::audit_command::audit_command; +use crate::cli::_helpers::audit_command::{audit_compare_command, audit_sample_command}; use crate::cli::_helpers::census_command::census_command; use crate::cli::_helpers::pending_command::pending_command; use crate::cli::_helpers::verify_command::verify_command; @@ -29,6 +29,9 @@ pub fn run_cli() -> ExitCode { Command::Census(arguments) => census_command(arguments, &mut stdout, &mut stderr), Command::Pending(arguments) => pending_command(arguments, &mut stderr), Command::Verify(arguments) => verify_command(arguments, &mut stdout, &mut stderr), - Command::Audit(arguments) => audit_command(arguments, &mut stdout, &mut stderr), + Command::Audit(arguments) => match &arguments.sample { + Some(sample) => audit_sample_command(arguments, sample, &mut stderr), + None => audit_compare_command(arguments, &mut stdout, &mut stderr), + }, } } diff --git a/crates/sqlquack/tests/cli.rs b/crates/sqlquack/tests/cli.rs new file mode 100644 index 000000000..a2288394d --- /dev/null +++ b/crates/sqlquack/tests/cli.rs @@ -0,0 +1,6 @@ +#[path = "cli/helpers.rs"] +mod helpers; +#[path = "cli/test_evidence_commands.rs"] +mod test_evidence_commands; +#[path = "cli/test_types.rs"] +mod test_types; diff --git a/crates/sqlquack/tests/evidence/helpers.rs b/crates/sqlquack/tests/cli/helpers.rs similarity index 66% rename from crates/sqlquack/tests/evidence/helpers.rs rename to crates/sqlquack/tests/cli/helpers.rs index 863e5cf5b..2c8fa129a 100644 --- a/crates/sqlquack/tests/evidence/helpers.rs +++ b/crates/sqlquack/tests/cli/helpers.rs @@ -1,21 +1,24 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use walkdir::WalkDir; + use crate::test_types::CommandOutcome; fn repository_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } -fn copy_tree(from: &Path, to: &Path) { - std::fs::create_dir_all(to).expect("directory created"); - for entry in std::fs::read_dir(from).expect("directory listed").flatten() { - let target = to.join(entry.file_name()); - if entry.path().is_dir() { - copy_tree(&entry.path(), &target); - } else { - std::fs::copy(entry.path(), &target).expect("file copied"); - } +fn copy_files(from: &Path, to: &Path) { + for entry in WalkDir::new(from) + .into_iter() + .flatten() + .filter(|entry| entry.file_type().is_file()) + { + let target = to.join(entry.path().strip_prefix(from).expect("inside the tree")); + std::fs::create_dir_all(target.parent().expect("a parent directory")) + .expect("directory created"); + std::fs::copy(entry.path(), &target).expect("file copied"); } } @@ -28,7 +31,7 @@ pub(crate) fn run_on_repository_cases(arguments: &[&str], mention: &str) -> Comm directory.path().join("sqlquack.toml"), ) .expect("policy copied"); - copy_tree(&root.join("cases"), &directory.path().join("cases")); + copy_files(&root.join("cases"), &directory.path().join("cases")); let output = Command::new(env!("CARGO_BIN_EXE_sqlquack")) .args(arguments) .current_dir(directory.path()) diff --git a/crates/sqlquack/tests/evidence/test_evidence_commands.rs b/crates/sqlquack/tests/cli/test_evidence_commands.rs similarity index 97% rename from crates/sqlquack/tests/evidence/test_evidence_commands.rs rename to crates/sqlquack/tests/cli/test_evidence_commands.rs index f0be6da62..27835e4a6 100644 --- a/crates/sqlquack/tests/evidence/test_evidence_commands.rs +++ b/crates/sqlquack/tests/cli/test_evidence_commands.rs @@ -7,7 +7,7 @@ fn given_repository_policy_and_cases_when_running_evidence_commands_then_they_lo CommandTestCase { description: "pending writes one file per seed case", arguments: &["pending"], - mention: "9 pending case files", + mention: "9 pending case files in", expected_outcome: CommandOutcome { exit_code: Some(0), pending_files: 9, diff --git a/crates/sqlquack/tests/evidence/test_types.rs b/crates/sqlquack/tests/cli/test_types.rs similarity index 100% rename from crates/sqlquack/tests/evidence/test_types.rs rename to crates/sqlquack/tests/cli/test_types.rs diff --git a/crates/sqlquack/tests/evidence.rs b/crates/sqlquack/tests/evidence.rs deleted file mode 100644 index e35f47bfe..000000000 --- a/crates/sqlquack/tests/evidence.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[path = "evidence/helpers.rs"] -mod helpers; -#[path = "evidence/test_evidence_commands.rs"] -mod test_evidence_commands; -#[path = "evidence/test_types.rs"] -mod test_types; From ed1bf488a16e056ee5f4c48f13f0c60a6391577c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:33:51 +0100 Subject: [PATCH 05/11] feat: Snowflake recorder, append-only guard and CI wiring --- .github/workflows/ci.yml | 13 +- Makefile | 24 ++- .../src/cases/_helpers/source_sql.rs | 2 +- .../src/cases/tests/test_load_cases.rs | 2 +- crates/sqlquack-evidence/src/constants.rs | 4 +- .../src/verify/tests/helpers.rs | 8 +- fensu.toml | 2 +- pyproject.toml | 13 +- recorder/pyproject.toml | 2 +- .../{run_recorder.py => _run_recorder.py} | 57 ++++--- .../pending/_helpers/__init__.py | 1 + .../pending/_helpers/parsing.py | 49 ++++++ .../pending/main/load_pending.py | 54 +------ .../recording/_helpers/batches.py | 11 +- .../_helpers/results.py} | 44 +++-- .../recording/_helpers/running.py | 24 +++ .../{ => recording}/constants.py | 22 ++- .../{errors.py => recording/exceptions.py} | 0 .../recording/main/record_pending.py | 40 ++--- .../sqlquack_record/{ => recording}/models.py | 16 +- .../sqlquack_record/{ => recording}/types.py | 9 +- .../src/sqlquack_record/results/__init__.py | 1 - .../results/_helpers/__init__.py | 1 - .../sqlquack_record/results/_helpers/files.py | 18 --- .../sqlquack_record/results/main/__init__.py | 1 - .../snowflake/_helpers/connections.py | 47 ++++++ .../snowflake/_helpers/cursors.py | 44 +++++ .../snowflake/classes/__init__.py | 1 + .../snowflake_session.py} | 50 ++---- .../snowflake/main/open_snowflake_sessions.py | 30 ++-- .../src/sqlquack_record/snowflake/models.py | 2 +- recorder/tests/recording/__init__.py | 1 - recorder/tests/recording/helpers.py | 140 ---------------- scripts/evidence_guard/_helpers/git.py | 16 +- scripts/evidence_guard/_helpers/metadata.py | 35 ++-- scripts/evidence_guard/constants.py | 7 + scripts/evidence_guard/main/check.py | 24 +-- tests/scripts/__init__.py | 1 - tests/unit/__init__.py | 1 + .../tests => tests/unit/recorder}/__init__.py | 0 tests/unit/recorder/src/__init__.py | 1 + .../recorder/src/sqlquack_record/__init__.py | 1 + .../src/sqlquack_record/pending/__init__.py | 1 + .../sqlquack_record/pending/_test_types.py | 14 ++ .../src/sqlquack_record/pending/helpers.py | 33 ++++ .../pending/test_load_pending.py | 60 +++++++ .../src/sqlquack_record/recording/__init__.py | 1 + .../sqlquack_record}/recording/_test_types.py | 12 +- .../src/sqlquack_record/recording/helpers.py | 153 ++++++++++++++++++ .../recording/test_record_pending.py | 64 ++++---- .../src/sqlquack_record/snowflake/__init__.py | 1 + .../sqlquack_record/snowflake/_test_types.py | 14 ++ .../src/sqlquack_record/snowflake/helpers.py | 43 +++++ .../snowflake/test_connect_arguments.py | 82 ++++++++++ tests/unit/scripts/__init__.py | 1 + .../scripts/evidence_guard/__init__.py | 0 .../scripts/evidence_guard/_test_types.py | 6 +- .../scripts/evidence_guard/helpers.py | 54 +++++-- .../scripts/evidence_guard/test_check.py | 26 +-- uv.lock | 30 +++- 60 files changed, 946 insertions(+), 468 deletions(-) rename recorder/src/sqlquack_record/cli/main/{run_recorder.py => _run_recorder.py} (58%) create mode 100644 recorder/src/sqlquack_record/pending/_helpers/__init__.py create mode 100644 recorder/src/sqlquack_record/pending/_helpers/parsing.py rename recorder/src/sqlquack_record/{results/main/write_result.py => recording/_helpers/results.py} (55%) create mode 100644 recorder/src/sqlquack_record/recording/_helpers/running.py rename recorder/src/sqlquack_record/{ => recording}/constants.py (56%) rename recorder/src/sqlquack_record/{errors.py => recording/exceptions.py} (100%) rename recorder/src/sqlquack_record/{ => recording}/models.py (79%) rename recorder/src/sqlquack_record/{ => recording}/types.py (72%) delete mode 100644 recorder/src/sqlquack_record/results/__init__.py delete mode 100644 recorder/src/sqlquack_record/results/_helpers/__init__.py delete mode 100644 recorder/src/sqlquack_record/results/_helpers/files.py delete mode 100644 recorder/src/sqlquack_record/results/main/__init__.py create mode 100644 recorder/src/sqlquack_record/snowflake/_helpers/connections.py create mode 100644 recorder/src/sqlquack_record/snowflake/_helpers/cursors.py create mode 100644 recorder/src/sqlquack_record/snowflake/classes/__init__.py rename recorder/src/sqlquack_record/snowflake/{_helpers/session.py => classes/snowflake_session.py} (62%) delete mode 100644 recorder/tests/recording/__init__.py delete mode 100644 recorder/tests/recording/helpers.py delete mode 100644 tests/scripts/__init__.py create mode 100644 tests/unit/__init__.py rename {recorder/tests => tests/unit/recorder}/__init__.py (100%) create mode 100644 tests/unit/recorder/src/__init__.py create mode 100644 tests/unit/recorder/src/sqlquack_record/__init__.py create mode 100644 tests/unit/recorder/src/sqlquack_record/pending/__init__.py create mode 100644 tests/unit/recorder/src/sqlquack_record/pending/_test_types.py create mode 100644 tests/unit/recorder/src/sqlquack_record/pending/helpers.py create mode 100644 tests/unit/recorder/src/sqlquack_record/pending/test_load_pending.py create mode 100644 tests/unit/recorder/src/sqlquack_record/recording/__init__.py rename {recorder/tests => tests/unit/recorder/src/sqlquack_record}/recording/_test_types.py (53%) create mode 100644 tests/unit/recorder/src/sqlquack_record/recording/helpers.py rename {recorder/tests => tests/unit/recorder/src/sqlquack_record}/recording/test_record_pending.py (54%) create mode 100644 tests/unit/recorder/src/sqlquack_record/snowflake/__init__.py create mode 100644 tests/unit/recorder/src/sqlquack_record/snowflake/_test_types.py create mode 100644 tests/unit/recorder/src/sqlquack_record/snowflake/helpers.py create mode 100644 tests/unit/recorder/src/sqlquack_record/snowflake/test_connect_arguments.py create mode 100644 tests/unit/scripts/__init__.py rename tests/{ => unit}/scripts/evidence_guard/__init__.py (100%) rename tests/{ => unit}/scripts/evidence_guard/_test_types.py (65%) rename tests/{ => unit}/scripts/evidence_guard/helpers.py (52%) rename tests/{ => unit}/scripts/evidence_guard/test_check.py (76%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24d196b11..8eb04ea5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,17 @@ jobs: env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: "0" + # The Snowflake recorder is never built or run in CI. + RECORDER: "0" + # `uv run` would otherwise sync the default groups, recorder included. + UV_NO_SYNC: "1" + # The append-only guard compares recorded results with the pull request base or the + # previous main commit. + EVIDENCE_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: astral-sh/setup-uv@v5 with: version: "0.12.3" @@ -36,8 +45,8 @@ jobs: shared-key: verify - name: Install Python run: uv python install 3.12 - - name: Install development tools - run: uv sync --locked + - name: Install development tools without the recorder + run: uv sync --locked --no-group recorder - name: Run static checks run: make check-ci - name: Run tests diff --git a/Makefile b/Makefile index fc81ec9d9..3a0ccb90e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,19 @@ SHELL := /bin/bash -.PHONY: format rust-lint python-check fensu check-ci test census +# CI sets RECORDER=0: the Snowflake recorder is never installed, type-checked or tested there. +RECORDER ?= 1 +# The revision recorded results are compared against by the append-only guard. +EVIDENCE_BASE ?= origin/main + +ifeq ($(RECORDER),1) +PYTHON_TYPED := . +PYTHON_TESTS := tests +else +PYTHON_TYPED := scripts tests/unit/scripts +PYTHON_TESTS := tests/unit/scripts +endif + +.PHONY: format rust-lint python-check fensu evidence-guard check-ci test census format: cargo fmt --all @@ -13,16 +26,21 @@ rust-lint: python-check: uv run ruff format --check . uv run ruff check . - uv run ty check + uv run ty check $(PYTHON_TYPED) fensu: uv run fensu check +# Fails when a change modifies or deletes a recorded result, or adds an invalid one. +evidence-guard: + uv run python -m scripts.check_evidence_append_only --base $(EVIDENCE_BASE) + # The static gate: CI and the pre-push verify hook run exactly this. -check-ci: rust-lint python-check fensu +check-ci: rust-lint python-check fensu evidence-guard test: cargo test --workspace --locked + uv run pytest $(PYTHON_TESTS) -n auto --dist loadfile # Example: make census CENSUS_ARGS="path/to/sql --json" census: diff --git a/crates/sqlquack-evidence/src/cases/_helpers/source_sql.rs b/crates/sqlquack-evidence/src/cases/_helpers/source_sql.rs index e431f72b8..84591df2b 100644 --- a/crates/sqlquack-evidence/src/cases/_helpers/source_sql.rs +++ b/crates/sqlquack-evidence/src/cases/_helpers/source_sql.rs @@ -16,7 +16,7 @@ pub(crate) fn source_sql(case: &CaseDefinition) -> String { ); match (&case.expression, &case.query) { (Some(expression), _) => format!( - "{input}SELECT {ORDINAL_COLUMN}, {expression} AS {VALUE_COLUMN}\nFROM {INPUT_RELATION}\nORDER BY {ORDINAL_COLUMN}" + "{input}SELECT {expression} AS {VALUE_COLUMN}\nFROM {INPUT_RELATION}\nORDER BY {ORDINAL_COLUMN}" ), (None, Some(query)) => format!("{input}{}", query.trim()), (None, None) => input, diff --git a/crates/sqlquack-evidence/src/cases/tests/test_load_cases.rs b/crates/sqlquack-evidence/src/cases/tests/test_load_cases.rs index 0ab49f570..e0796d83d 100644 --- a/crates/sqlquack-evidence/src/cases/tests/test_load_cases.rs +++ b/crates/sqlquack-evidence/src/cases/tests/test_load_cases.rs @@ -79,7 +79,7 @@ fn given_case_when_expanding_then_inputs_become_typed_rows_before_the_case_sql() SELECT 1 AS ordinal, CAST('gift' AS VARCHAR) AS a, CAST(NULL AS VARCHAR) AS b\n \ UNION ALL\n \ SELECT 2 AS ordinal, CAST('' AS VARCHAR) AS a, CAST('wrap' AS VARCHAR) AS b\n)\n\ - SELECT ordinal, CONCAT(a, b) AS value\nFROM input\nORDER BY ordinal", + SELECT CONCAT(a, b) AS value\nFROM input\nORDER BY ordinal", }, SourceSqlTestCase { description: "a query follows the input relation unchanged", diff --git a/crates/sqlquack-evidence/src/constants.rs b/crates/sqlquack-evidence/src/constants.rs index 89dda9109..abd4565af 100644 --- a/crates/sqlquack-evidence/src/constants.rs +++ b/crates/sqlquack-evidence/src/constants.rs @@ -21,10 +21,10 @@ pub const EVIDENCE_FORMAT: u32 = 1; /// The source dialect name recorded in hashes and pending files. pub const SOURCE_DIALECT: &str = "snowflake"; -/// The result column holding the input row number in expression cases. +/// The input column holding the row number; expression results are ordered by it. pub const ORDINAL_COLUMN: &str = "ordinal"; -/// The result column holding the expression value in expression cases. +/// The single result column of expression cases. pub const VALUE_COLUMN: &str = "value"; /// The relation that holds the case input rows. diff --git a/crates/sqlquack-evidence/src/verify/tests/helpers.rs b/crates/sqlquack-evidence/src/verify/tests/helpers.rs index 3548d456c..e309a55b6 100644 --- a/crates/sqlquack-evidence/src/verify/tests/helpers.rs +++ b/crates/sqlquack-evidence/src/verify/tests/helpers.rs @@ -42,11 +42,6 @@ snowflake = "NUMBER(p,s)" duckdb = "DECIMAL(p,s)" reason = "same values" -[[comparator.type_equality]] -snowflake = "NUMBER(10,0)" -duckdb = "INTEGER" -reason = "test fixture: DuckDB stand-ins describe INTEGER as NUMBER(10,0)" - [[comparator.type_equality]] snowflake = "VARCHAR(n)" duckdb = "VARCHAR" @@ -88,9 +83,8 @@ tags = [] "#; /// How DuckDB result types of the fixture cases are described as warehouse columns. -fn fixture_column_types() -> [(DataType, (&'static str, i64, i64)); 3] { +fn fixture_column_types() -> [(DataType, (&'static str, i64, i64)); 2] { [ - (DataType::Int32, ("FIXED", 10, 0)), (DataType::Decimal128(10, 2), ("FIXED", 10, 2)), (DataType::Decimal128(11, 2), ("FIXED", 11, 2)), ] diff --git a/fensu.toml b/fensu.toml index 2da081db8..b52685d1b 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1,7 +1,7 @@ [targets.python] analyzer = "python" roots = ["recorder/src/sqlquack_record"] -tests = ["recorder/tests", "tests"] +tests = ["tests"] tooling = ["scripts"] select = ["FF"] diff --git a/pyproject.toml b/pyproject.toml index 1bdc6c405..03ea361a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,13 +9,16 @@ dependencies = [] dev = [ "fensu>=0.27.0", "pytest>=8.4", + "pytest-xdist>=3.8", "ruff>=0.12.0", - "sqlquack-record", "ty>=0.0.1a19", ] +# The Snowflake recorder. Maintainers install it locally; CI never builds or runs it. +recorder = ["sqlquack-record"] [tool.uv] package = false +default-groups = ["dev", "recorder"] [tool.uv.workspace] members = ["recorder"] @@ -24,7 +27,7 @@ members = ["recorder"] sqlquack-record = { workspace = true } [tool.pytest.ini_options] -testpaths = ["recorder/tests", "tests"] +testpaths = ["tests"] addopts = "--import-mode=importlib" pythonpath = ["."] @@ -35,5 +38,9 @@ target-version = "py312" [tool.ruff.lint] select = ["B", "E", "F", "I", "PLR0913", "PLR0915", "UP"] +[tool.ruff.lint.per-file-ignores] +"tests/**/test_*.py" = ["E501"] +"tests/**/helpers.py" = ["PLR0913"] + [tool.ruff.lint.isort] -known-first-party = ["sqlquack_record", "scripts", "tests", "recorder"] +known-first-party = ["sqlquack_record", "scripts", "tests"] diff --git a/recorder/pyproject.toml b/recorder/pyproject.toml index a7c1d0f7d..60cb3b725 100644 --- a/recorder/pyproject.toml +++ b/recorder/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ ] [project.scripts] -sqlquack-record = "sqlquack_record.cli.main.run_recorder:main" +sqlquack-record = "sqlquack_record.cli.main._run_recorder:main" [build-system] requires = ["hatchling"] diff --git a/recorder/src/sqlquack_record/cli/main/run_recorder.py b/recorder/src/sqlquack_record/cli/main/_run_recorder.py similarity index 58% rename from recorder/src/sqlquack_record/cli/main/run_recorder.py rename to recorder/src/sqlquack_record/cli/main/_run_recorder.py index 3893dd464..2d1753b4e 100644 --- a/recorder/src/sqlquack_record/cli/main/run_recorder.py +++ b/recorder/src/sqlquack_record/cli/main/_run_recorder.py @@ -8,7 +8,8 @@ from datetime import UTC, datetime from pathlib import Path -from sqlquack_record.constants import ( +from sqlquack_record.pending.main.load_pending import load_pending +from sqlquack_record.recording.constants import ( CONNECTION_ENVIRONMENT, CONNECTION_OVERRIDES, DEFAULT_BATCH_SIZE, @@ -16,26 +17,16 @@ DEFAULT_RESULTS_DIR, WAREHOUSE_ENVIRONMENT, ) -from sqlquack_record.errors import RecorderError -from sqlquack_record.models import PendingCase, RecordSummary -from sqlquack_record.pending.main.load_pending import load_pending +from sqlquack_record.recording.exceptions import RecorderError from sqlquack_record.recording.main.record_pending import record_pending +from sqlquack_record.recording.models import PendingCase, RecordSummary from sqlquack_record.snowflake.main.open_snowflake_sessions import snowflake_sessions from sqlquack_record.snowflake.models import ConnectionSettings def main(argv: list[str] | None = None) -> int: - """Record every pending case on Snowflake.""" - parser: argparse.ArgumentParser = argparse.ArgumentParser( - prog="sqlquack-record", description=__doc__ - ) - parser.add_argument("--pending", type=Path, default=Path(DEFAULT_PENDING_DIR)) - parser.add_argument("--results", type=Path, default=Path(DEFAULT_RESULTS_DIR)) - parser.add_argument("--connection", default=os.environ.get(CONNECTION_ENVIRONMENT)) - parser.add_argument("--warehouse", default=os.environ.get(WAREHOUSE_ENVIRONMENT)) - parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE) - parser.add_argument("--dry-run", action="store_true") - arguments: argparse.Namespace = parser.parse_args(argv) + """Record every pending case on Snowflake; results are written once and never replaced.""" + arguments: argparse.Namespace = _parser().parse_args(argv) try: cases: list[PendingCase] = load_pending(arguments.pending) print( @@ -54,24 +45,42 @@ def main(argv: list[str] | None = None) -> int: if os.environ.get(variable) ), ) - print("sqlquack-record: connecting to Snowflake…", file=sys.stderr) + print("sqlquack-record: connecting to Snowflake and recording…", file=sys.stderr) summary: RecordSummary = record_pending( - cases, - arguments.results, - snowflake_sessions(settings), - arguments.batch_size, - lambda: datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"), + cases=cases, + results=arguments.results, + open_session=snowflake_sessions(settings), + batch_size=arguments.batch_size, + clock=lambda: datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"), ) except RecorderError as error: print(f"sqlquack-record: failed: {error}", file=sys.stderr) return 1 print( f"sqlquack-record: done, recorded {summary.recorded} results " - f"({summary.errors} warehouse errors) into {arguments.results}", + f"({summary.failures} warehouse SQL errors) into {arguments.results}", file=sys.stderr, ) return 0 -if __name__ == "__main__": - raise SystemExit(main()) +def _parser() -> argparse.ArgumentParser: + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="sqlquack-record", + description="Record Snowflake results for SQLQuack's pending cases as Arrow IPC files.", + ) + parser.add_argument("--pending", type=Path, default=Path(DEFAULT_PENDING_DIR)) + parser.add_argument("--results", type=Path, default=Path(DEFAULT_RESULTS_DIR)) + parser.add_argument( + "--connection", + default=os.environ.get(CONNECTION_ENVIRONMENT), + help="a connection name from ~/.snowflake/connections.toml", + ) + parser.add_argument( + "--warehouse", + default=os.environ.get(WAREHOUSE_ENVIRONMENT), + help="the warehouse to use instead of the connection's", + ) + parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE) + parser.add_argument("--dry-run", action="store_true", help="list pending cases and stop") + return parser diff --git a/recorder/src/sqlquack_record/pending/_helpers/__init__.py b/recorder/src/sqlquack_record/pending/_helpers/__init__.py new file mode 100644 index 000000000..e592b19d0 --- /dev/null +++ b/recorder/src/sqlquack_record/pending/_helpers/__init__.py @@ -0,0 +1 @@ +"""pending _helpers package.""" diff --git a/recorder/src/sqlquack_record/pending/_helpers/parsing.py b/recorder/src/sqlquack_record/pending/_helpers/parsing.py new file mode 100644 index 000000000..3e478b83b --- /dev/null +++ b/recorder/src/sqlquack_record/pending/_helpers/parsing.py @@ -0,0 +1,49 @@ +"""Parsing one pending case file.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from sqlquack_record.recording.constants import ( + EVIDENCE_FORMAT, + HASH_NAME_LENGTH, + HEX_DIGITS, + PENDING_KEYS, +) +from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.recording.models import PendingCase +from sqlquack_record.recording.types import SessionValue + + +def is_case_file(path: Path) -> bool: + """Whether `path` is named `.json`.""" + return len(path.stem) == HASH_NAME_LENGTH and set(path.stem) <= HEX_DIGITS + + +def parse_pending(path: Path) -> PendingCase: + """The pending case in `path`; its keys and hash must match the format exactly.""" + document: object = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(document, dict) or set(document) != PENDING_KEYS: + raise RecorderError(f"{path.name}: not a pending case file") + if document["format"] != EVIDENCE_FORMAT or document["case_hash"] != path.stem: + raise RecorderError(f"{path.name}: unexpected format or case hash") + parameters: object = document["session_parameters"] + if not isinstance(parameters, dict): + raise RecorderError(f"{path.name}: session_parameters is not an object") + return PendingCase( + case_hash=str(document["case_hash"]), + case_reference=str(document["case_reference"]), + source_dialect=str(document["source_dialect"]), + session_parameters=tuple( + sorted((str(key), _value(path=path, value=value)) for key, value in parameters.items()) + ), + source_sql=str(document["source_sql"]), + duckdb_version=str(document["duckdb_version"]), + ) + + +def _value(*, path: Path, value: object) -> SessionValue: + if isinstance(value, bool | int | str): + return value + raise RecorderError(f"{path.name}: unsupported session parameter value {value!r}") diff --git a/recorder/src/sqlquack_record/pending/main/load_pending.py b/recorder/src/sqlquack_record/pending/main/load_pending.py index 4be8e6fd5..40248182e 100644 --- a/recorder/src/sqlquack_record/pending/main/load_pending.py +++ b/recorder/src/sqlquack_record/pending/main/load_pending.py @@ -2,24 +2,10 @@ from __future__ import annotations -import json from pathlib import Path -from sqlquack_record.constants import EVIDENCE_FORMAT, HASH_NAME_LENGTH -from sqlquack_record.errors import RecorderError -from sqlquack_record.models import PendingCase, SessionValue - -_KEYS: frozenset[str] = frozenset( - { - "format", - "case_hash", - "case_reference", - "source_dialect", - "session_parameters", - "source_sql", - "duckdb_version", - } -) +from sqlquack_record.pending._helpers.parsing import is_case_file, parse_pending +from sqlquack_record.recording.models import PendingCase def load_pending(directory: Path) -> list[PendingCase]: @@ -27,40 +13,6 @@ def load_pending(directory: Path) -> list[PendingCase]: if not directory.is_dir(): return [] cases: list[PendingCase] = [ - _parse(path) for path in sorted(directory.glob("*.json")) if _is_case_file(path) + parse_pending(path) for path in sorted(directory.glob("*.json")) if is_case_file(path) ] return sorted(cases, key=lambda case: case.case_reference) - - -def _is_case_file(path: Path) -> bool: - stem: str = path.stem - return len(stem) == HASH_NAME_LENGTH and all( - character in "0123456789abcdef" for character in stem - ) - - -def _parse(path: Path) -> PendingCase: - document: object = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(document, dict) or set(document) != _KEYS: - raise RecorderError(f"{path.name}: not a pending case file") - if document["format"] != EVIDENCE_FORMAT or document["case_hash"] != path.stem: - raise RecorderError(f"{path.name}: unexpected format or case hash") - parameters: object = document["session_parameters"] - if not isinstance(parameters, dict): - raise RecorderError(f"{path.name}: session_parameters is not an object") - return PendingCase( - case_hash=str(document["case_hash"]), - case_reference=str(document["case_reference"]), - source_dialect=str(document["source_dialect"]), - session_parameters=tuple( - sorted((str(key), _value(path, value)) for key, value in parameters.items()) - ), - source_sql=str(document["source_sql"]), - duckdb_version=str(document["duckdb_version"]), - ) - - -def _value(path: Path, value: object) -> SessionValue: - if isinstance(value, bool | int | str): - return value - raise RecorderError(f"{path.name}: unsupported session parameter value {value!r}") diff --git a/recorder/src/sqlquack_record/recording/_helpers/batches.py b/recorder/src/sqlquack_record/recording/_helpers/batches.py index 91c3b5ccc..6600e07d7 100644 --- a/recorder/src/sqlquack_record/recording/_helpers/batches.py +++ b/recorder/src/sqlquack_record/recording/_helpers/batches.py @@ -4,20 +4,19 @@ from collections.abc import Iterator, Sequence -from sqlquack_record.models import PendingCase, SessionValue +from sqlquack_record.recording.models import PendingCase +from sqlquack_record.recording.types import SessionParameters -def by_session( - cases: Sequence[PendingCase], -) -> dict[tuple[tuple[str, SessionValue], ...], list[PendingCase]]: +def by_session(cases: Sequence[PendingCase]) -> dict[SessionParameters, list[PendingCase]]: """Cases grouped by their session parameters, in first-seen order.""" - groups: dict[tuple[tuple[str, SessionValue], ...], list[PendingCase]] = {} + groups: dict[SessionParameters, list[PendingCase]] = {} for case in cases: groups.setdefault(case.session_parameters, []).append(case) return groups -def batches(cases: Sequence[PendingCase], size: int) -> Iterator[Sequence[PendingCase]]: +def batches(*, cases: Sequence[PendingCase], size: int) -> Iterator[Sequence[PendingCase]]: """Consecutive slices of at most `size` cases.""" step: int = max(size, 1) for start in range(0, len(cases), step): diff --git a/recorder/src/sqlquack_record/results/main/write_result.py b/recorder/src/sqlquack_record/recording/_helpers/results.py similarity index 55% rename from recorder/src/sqlquack_record/results/main/write_result.py rename to recorder/src/sqlquack_record/recording/_helpers/results.py index 587d431b0..9d5ffa235 100644 --- a/recorder/src/sqlquack_record/results/main/write_result.py +++ b/recorder/src/sqlquack_record/recording/_helpers/results.py @@ -1,33 +1,40 @@ -"""Write one recorded result: the Arrow IPC file, then its metadata.""" +"""Writing one recorded result: the Arrow IPC file, then its metadata.""" from __future__ import annotations import hashlib import json +import os from pathlib import Path import pyarrow as pa -from sqlquack_record.constants import EVIDENCE_FORMAT, RECORDER_NAME, RECORDER_VERSION -from sqlquack_record.models import PendingCase, RecordingContext, StatementResult -from sqlquack_record.results._helpers.files import create_exclusively +from sqlquack_record.recording.constants import ( + ERROR_OUTCOME, + EVIDENCE_FORMAT, + RECORDER_NAME, + RECORDER_VERSION, + ROWS_OUTCOME, +) +from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.recording.models import PendingCase, RecordingContext, StatementResult def write_result( - directory: Path, case: PendingCase, result: StatementResult, context: RecordingContext + *, directory: Path, case: PendingCase, result: StatementResult, context: RecordingContext ) -> None: """Store `result` for `case` without converting a single value.""" arrow_sha256: str | None = None row_count: int | None = None if result.table is not None: arrow_bytes: bytes = _ipc_file(result.table) - create_exclusively(directory / f"{case.case_hash}.arrow", arrow_bytes) + _create_exclusively(path=directory / f"{case.case_hash}.arrow", content=arrow_bytes) arrow_sha256 = hashlib.sha256(arrow_bytes).hexdigest() row_count = result.table.num_rows metadata: dict[str, object] = { "format": EVIDENCE_FORMAT, "case_hash": case.case_hash, - "outcome": "rows" if result.table is not None else "error", + "outcome": ROWS_OUTCOME if result.table is not None else ERROR_OUTCOME, "recorder": f"{RECORDER_NAME} {RECORDER_VERSION}", "connector": context.connector, "query_id": result.query_id, @@ -50,16 +57,16 @@ def write_result( "row_count": row_count, "arrow_sha256": arrow_sha256, "error": None - if result.error is None + if result.failure is None else { - "errno": result.error.errno, - "sqlstate": result.error.sqlstate, - "message": result.error.message, + "errno": result.failure.errno, + "sqlstate": result.failure.sqlstate, + "message": result.failure.message, }, } - create_exclusively( - directory / f"{case.case_hash}.json", - (json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode("utf-8"), + _create_exclusively( + path=directory / f"{case.case_hash}.json", + content=(json.dumps(metadata, indent=2, sort_keys=True) + "\n").encode("utf-8"), ) @@ -68,3 +75,12 @@ def _ipc_file(table: pa.Table) -> bytes: with pa.ipc.new_file(sink, table.schema) as writer: writer.write_table(table) return sink.getvalue().to_pybytes() + + +def _create_exclusively(*, path: Path, content: bytes) -> None: + try: + descriptor: int = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) + except FileExistsError as error: + raise RecorderError(f"{path.name} already exists; results are append-only") from error + with os.fdopen(descriptor, "wb") as handle: + handle.write(content) diff --git a/recorder/src/sqlquack_record/recording/_helpers/running.py b/recorder/src/sqlquack_record/recording/_helpers/running.py new file mode 100644 index 000000000..5f71d9892 --- /dev/null +++ b/recorder/src/sqlquack_record/recording/_helpers/running.py @@ -0,0 +1,24 @@ +"""Running one batch of cases.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.recording.models import PendingCase, StatementResult +from sqlquack_record.recording.types import WarehouseSession + + +def run_cases(*, session: WarehouseSession, batch: Sequence[PendingCase]) -> list[StatementResult]: + """One result per case: one request for the batch, or one per case after a SQL error.""" + statements: list[str] = [case.source_sql for case in batch] + batched: list[StatementResult] | None = ( + session.run_batch(statements) if len(statements) > 1 else None + ) + if batched is None: + return [session.run_one(statement) for statement in statements] + if len(batched) != len(statements): + raise RecorderError( + f"the warehouse returned {len(batched)} results for {len(statements)} statements" + ) + return batched diff --git a/recorder/src/sqlquack_record/constants.py b/recorder/src/sqlquack_record/recording/constants.py similarity index 56% rename from recorder/src/sqlquack_record/constants.py rename to recorder/src/sqlquack_record/recording/constants.py index d5a4e2736..2d3a035c8 100644 --- a/recorder/src/sqlquack_record/constants.py +++ b/recorder/src/sqlquack_record/recording/constants.py @@ -18,6 +18,26 @@ "SNOWFLAKE_AUTHENTICATOR": "authenticator", "SNOWFLAKE_HOST": "host", } -OAUTH_AUTHORIZATION_CODE: str = "oauth_authorization_code" RECORDABLE_SQLSTATE_CLASSES: frozenset[str] = frozenset({"0A", "21", "22", "42"}) HASH_NAME_LENGTH: int = 64 +HEX_DIGITS: frozenset[str] = frozenset("0123456789abcdef") +PENDING_KEYS: frozenset[str] = frozenset( + { + "format", + "case_hash", + "case_reference", + "source_dialect", + "session_parameters", + "source_sql", + "duckdb_version", + } +) +ROWS_OUTCOME: str = "rows" +ERROR_OUTCOME: str = "error" +ACCOUNT_ARGUMENT: str = "account" +WAREHOUSE_ARGUMENT: str = "warehouse" +SESSION_PARAMETERS_ARGUMENT: str = "session_parameters" +CREDENTIAL_CACHE_DEFAULTS: dict[str, bool] = { + "client_store_temporary_credential": True, + "oauth_enable_refresh_tokens": True, +} diff --git a/recorder/src/sqlquack_record/errors.py b/recorder/src/sqlquack_record/recording/exceptions.py similarity index 100% rename from recorder/src/sqlquack_record/errors.py rename to recorder/src/sqlquack_record/recording/exceptions.py diff --git a/recorder/src/sqlquack_record/recording/main/record_pending.py b/recorder/src/sqlquack_record/recording/main/record_pending.py index 6946117ca..1b0195b2d 100644 --- a/recorder/src/sqlquack_record/recording/main/record_pending.py +++ b/recorder/src/sqlquack_record/recording/main/record_pending.py @@ -5,14 +5,20 @@ from collections.abc import Callable, Sequence from pathlib import Path -from sqlquack_record.errors import RecorderError -from sqlquack_record.models import PendingCase, RecordingContext, RecordSummary, StatementResult from sqlquack_record.recording._helpers.batches import batches, by_session -from sqlquack_record.results.main.write_result import write_result -from sqlquack_record.types import SessionFactory, WarehouseSession +from sqlquack_record.recording._helpers.results import write_result +from sqlquack_record.recording._helpers.running import run_cases +from sqlquack_record.recording.models import ( + PendingCase, + RecordingContext, + RecordSummary, + StatementResult, +) +from sqlquack_record.recording.types import SessionFactory, WarehouseSession def record_pending( + *, cases: Sequence[PendingCase], results: Path, open_session: SessionFactory, @@ -25,7 +31,7 @@ def record_pending( case for case in cases if not (results / f"{case.case_hash}.json").exists() ] recorded: int = 0 - errors: int = 0 + failures: int = 0 for parameters, group in by_session(unrecorded).items(): session: WarehouseSession = open_session(parameters) try: @@ -34,26 +40,12 @@ def record_pending( connector=session.connector(), recorded_at=clock(), ) - for batch in batches(group, batch_size): - outcomes: list[StatementResult] = _run(session, batch) + for batch in batches(cases=group, size=batch_size): + outcomes: list[StatementResult] = run_cases(session=session, batch=batch) for case, outcome in zip(batch, outcomes, strict=True): - write_result(results, case, outcome, context) + write_result(directory=results, case=case, result=outcome, context=context) recorded += 1 - errors += outcome.error is not None + failures += outcome.failure is not None finally: session.close() - return RecordSummary(recorded=recorded, errors=errors) - - -def _run(session: WarehouseSession, batch: Sequence[PendingCase]) -> list[StatementResult]: - statements: list[str] = [case.source_sql for case in batch] - batched: list[StatementResult] | None = ( - session.run_batch(statements) if len(statements) > 1 else None - ) - if batched is not None: - if len(batched) != len(statements): - raise RecorderError( - f"the warehouse returned {len(batched)} results for {len(statements)} statements" - ) - return batched - return [session.run_one(statement) for statement in statements] + return RecordSummary(recorded=recorded, failures=failures) diff --git a/recorder/src/sqlquack_record/models.py b/recorder/src/sqlquack_record/recording/models.py similarity index 79% rename from recorder/src/sqlquack_record/models.py rename to recorder/src/sqlquack_record/recording/models.py index de52c557d..a4e276d89 100644 --- a/recorder/src/sqlquack_record/models.py +++ b/recorder/src/sqlquack_record/recording/models.py @@ -1,4 +1,4 @@ -"""Pending cases, statement results and recorded metadata.""" +"""Pending cases, statement results and recording facts.""" from __future__ import annotations @@ -6,8 +6,6 @@ import pyarrow as pa -SessionValue = bool | int | str - @dataclass(frozen=True) class PendingCase: @@ -16,7 +14,7 @@ class PendingCase: case_hash: str case_reference: str source_dialect: str - session_parameters: tuple[tuple[str, SessionValue], ...] + session_parameters: tuple[tuple[str, bool | int | str], ...] source_sql: str duckdb_version: str @@ -35,8 +33,8 @@ class ColumnDescription: @dataclass(frozen=True) -class RecordedError: - """A warehouse error recorded as a case outcome.""" +class WarehouseFailure: + """A warehouse SQL error recorded as a case outcome.""" errno: int | None sqlstate: str | None @@ -45,12 +43,12 @@ class RecordedError: @dataclass(frozen=True) class StatementResult: - """What the warehouse returned for one case: rows as Arrow, or an error.""" + """What the warehouse returned for one case: rows as Arrow, or a SQL error.""" query_id: str | None columns: tuple[ColumnDescription, ...] table: pa.Table | None - error: RecordedError | None + failure: WarehouseFailure | None @dataclass(frozen=True) @@ -67,4 +65,4 @@ class RecordSummary: """What a recording run did.""" recorded: int - errors: int + failures: int diff --git a/recorder/src/sqlquack_record/types.py b/recorder/src/sqlquack_record/recording/types.py similarity index 72% rename from recorder/src/sqlquack_record/types.py rename to recorder/src/sqlquack_record/recording/types.py index 4247c8334..3ebf495ed 100644 --- a/recorder/src/sqlquack_record/types.py +++ b/recorder/src/sqlquack_record/recording/types.py @@ -1,11 +1,14 @@ -"""The warehouse session the recorder drives.""" +"""Session parameter values and the warehouse session the recorder drives.""" from __future__ import annotations from collections.abc import Callable, Sequence from typing import Protocol -from sqlquack_record.models import SessionValue, StatementResult +from sqlquack_record.recording.models import StatementResult + +type SessionValue = bool | int | str +type SessionParameters = tuple[tuple[str, SessionValue], ...] class WarehouseSession(Protocol): @@ -32,4 +35,4 @@ def close(self) -> None: ... -SessionFactory = Callable[[tuple[tuple[str, SessionValue], ...]], WarehouseSession] +type SessionFactory = Callable[[SessionParameters], WarehouseSession] diff --git a/recorder/src/sqlquack_record/results/__init__.py b/recorder/src/sqlquack_record/results/__init__.py deleted file mode 100644 index ce4b6b24f..000000000 --- a/recorder/src/sqlquack_record/results/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""results package.""" diff --git a/recorder/src/sqlquack_record/results/_helpers/__init__.py b/recorder/src/sqlquack_record/results/_helpers/__init__.py deleted file mode 100644 index 64c372411..000000000 --- a/recorder/src/sqlquack_record/results/_helpers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""results _helpers package.""" diff --git a/recorder/src/sqlquack_record/results/_helpers/files.py b/recorder/src/sqlquack_record/results/_helpers/files.py deleted file mode 100644 index d5116e53d..000000000 --- a/recorder/src/sqlquack_record/results/_helpers/files.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Exclusive, append-only file creation.""" - -from __future__ import annotations - -import os -from pathlib import Path - -from sqlquack_record.errors import RecorderError - - -def create_exclusively(path: Path, content: bytes) -> None: - """Write `content` to `path`, failing if anything already exists there.""" - try: - descriptor: int = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) - except FileExistsError as error: - raise RecorderError(f"{path.name} already exists; results are append-only") from error - with os.fdopen(descriptor, "wb") as handle: - handle.write(content) diff --git a/recorder/src/sqlquack_record/results/main/__init__.py b/recorder/src/sqlquack_record/results/main/__init__.py deleted file mode 100644 index ef9e291a8..000000000 --- a/recorder/src/sqlquack_record/results/main/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""results main package.""" diff --git a/recorder/src/sqlquack_record/snowflake/_helpers/connections.py b/recorder/src/sqlquack_record/snowflake/_helpers/connections.py new file mode 100644 index 000000000..cbd8a329e --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/_helpers/connections.py @@ -0,0 +1,47 @@ +"""Resolving connection arguments from the connector's own configuration.""" + +from __future__ import annotations + +from typing import Any + +from snowflake.connector.config_manager import CONFIG_MANAGER + +from sqlquack_record.recording.constants import ( + ACCOUNT_ARGUMENT, + CREDENTIAL_CACHE_DEFAULTS, + QUERY_TAG, + SESSION_PARAMETERS_ARGUMENT, + WAREHOUSE_ARGUMENT, +) +from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.recording.types import SessionParameters +from sqlquack_record.snowflake.models import ConnectionSettings + + +def connect_arguments( + *, settings: ConnectionSettings, parameters: SessionParameters +) -> dict[str, Any]: + """Named or default connection, then cache defaults, overrides and the case's parameters.""" + overrides: dict[str, str] = dict(settings.overrides) + named: dict[str, Any] = {} if ACCOUNT_ARGUMENT in overrides else _connection(settings) + arguments: dict[str, Any] = {**CREDENTIAL_CACHE_DEFAULTS, **named, **overrides} + arguments[SESSION_PARAMETERS_ARGUMENT] = { + **dict(named.get(SESSION_PARAMETERS_ARGUMENT) or {}), + **dict(parameters), + "QUERY_TAG": QUERY_TAG, + } + if settings.warehouse is not None: + arguments[WAREHOUSE_ARGUMENT] = settings.warehouse + arguments["arrow_number_to_decimal"] = True + return arguments + + +def _connection(settings: ConnectionSettings) -> dict[str, Any]: + connections: Any = CONFIG_MANAGER["connections"] + name: str = settings.connection_name or str(CONFIG_MANAGER["default_connection_name"]) + if name not in connections: + raise RecorderError( + f"no Snowflake connection named '{name}' in the connector configuration; pass " + "--connection or set SNOWFLAKE_ACCOUNT and related variables" + ) + return dict(connections[name]) diff --git a/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py b/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py new file mode 100644 index 000000000..458bd0155 --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py @@ -0,0 +1,44 @@ +"""Reading connector cursors without converting values.""" + +from __future__ import annotations + +from typing import Any + +from snowflake.connector.constants import FIELD_ID_TO_NAME +from snowflake.connector.errors import ProgrammingError + +from sqlquack_record.recording.constants import RECORDABLE_SQLSTATE_CLASSES +from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.recording.models import ColumnDescription, StatementResult + + +def fetch(cursor: Any) -> StatementResult: + """The cursor's current result as the connector's Arrow table.""" + return StatementResult( + query_id=cursor.sfqid, + columns=tuple(_describe(column) for column in cursor.description), + table=cursor.fetch_arrow_all(force_return_table=True), + failure=None, + ) + + +def require_recordable(error: ProgrammingError) -> None: + """Raise unless `error` is a SQL error that belongs to the case rather than the session.""" + sqlstate: str = str(error.sqlstate or "") + if sqlstate[:2] not in RECORDABLE_SQLSTATE_CLASSES: + raise RecorderError( + f"Snowflake error {error.errno} (SQLSTATE {sqlstate or 'unknown'}) is not a case " + f"outcome; nothing was recorded: {error.msg}" + ) from error + + +def _describe(column: Any) -> ColumnDescription: + return ColumnDescription( + name=str(column.name), + type_code=int(column.type_code), + type_name=str(FIELD_ID_TO_NAME.get(column.type_code, column.type_code)), + precision=column.precision, + scale=column.scale, + internal_size=column.internal_size, + nullable=column.is_nullable, + ) diff --git a/recorder/src/sqlquack_record/snowflake/classes/__init__.py b/recorder/src/sqlquack_record/snowflake/classes/__init__.py new file mode 100644 index 000000000..b9f8ad837 --- /dev/null +++ b/recorder/src/sqlquack_record/snowflake/classes/__init__.py @@ -0,0 +1 @@ +"""snowflake classes package.""" diff --git a/recorder/src/sqlquack_record/snowflake/_helpers/session.py b/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py similarity index 62% rename from recorder/src/sqlquack_record/snowflake/_helpers/session.py rename to recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py index fcb1f443d..dbb2fe091 100644 --- a/recorder/src/sqlquack_record/snowflake/_helpers/session.py +++ b/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py @@ -6,12 +6,11 @@ from importlib.metadata import version from typing import Any -from snowflake.connector.constants import FIELD_ID_TO_NAME from snowflake.connector.errors import ProgrammingError -from sqlquack_record.constants import RECORDABLE_SQLSTATE_CLASSES -from sqlquack_record.errors import RecorderError -from sqlquack_record.models import ColumnDescription, RecordedError, StatementResult +from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.recording.models import StatementResult, WarehouseFailure +from sqlquack_record.snowflake._helpers.cursors import fetch, require_recordable class SnowflakeSession: @@ -39,13 +38,13 @@ def run_batch(self, statements: Sequence[str]) -> list[StatementResult] | None: cursor: Any = self._connection.cursor() try: cursor.execute(";\n".join(statements), num_statements=len(statements)) - results: list[StatementResult] = [_fetch(cursor)] + results: list[StatementResult] = [fetch(cursor)] for _ in statements[1:]: if not cursor.nextset(): raise RecorderError("the warehouse returned fewer results than statements") - results.append(_fetch(cursor)) + results.append(fetch(cursor)) except ProgrammingError as error: - _require_recordable(error) + require_recordable(error) return None finally: cursor.close() @@ -56,14 +55,14 @@ def run_one(self, statement: str) -> StatementResult: cursor: Any = self._connection.cursor() try: cursor.execute(statement) - return _fetch(cursor) + return fetch(cursor) except ProgrammingError as error: - _require_recordable(error) + require_recordable(error) return StatementResult( query_id=error.sfqid, columns=(), table=None, - error=RecordedError( + failure=WarehouseFailure( errno=error.errno, sqlstate=error.sqlstate, message=str(error.msg) ), ) @@ -81,34 +80,3 @@ def _scalar(self, statement: str) -> object: finally: cursor.close() return None if row is None else row[0] - - -def _fetch(cursor: Any) -> StatementResult: - table: Any = cursor.fetch_arrow_all(force_return_table=True) - return StatementResult( - query_id=cursor.sfqid, - columns=tuple(_describe(column) for column in cursor.description), - table=table, - error=None, - ) - - -def _describe(column: Any) -> ColumnDescription: - return ColumnDescription( - name=str(column.name), - type_code=int(column.type_code), - type_name=str(FIELD_ID_TO_NAME.get(column.type_code, column.type_code)), - precision=column.precision, - scale=column.scale, - internal_size=column.internal_size, - nullable=column.is_nullable, - ) - - -def _require_recordable(error: ProgrammingError) -> None: - sqlstate: str = str(error.sqlstate or "") - if sqlstate[:2] not in RECORDABLE_SQLSTATE_CLASSES: - raise RecorderError( - f"Snowflake error {error.errno} (SQLSTATE {sqlstate or 'unknown'}) is not a case " - f"outcome; nothing was recorded: {error.msg}" - ) from error diff --git a/recorder/src/sqlquack_record/snowflake/main/open_snowflake_sessions.py b/recorder/src/sqlquack_record/snowflake/main/open_snowflake_sessions.py index 89733ebb8..d577fa6dc 100644 --- a/recorder/src/sqlquack_record/snowflake/main/open_snowflake_sessions.py +++ b/recorder/src/sqlquack_record/snowflake/main/open_snowflake_sessions.py @@ -2,34 +2,24 @@ from __future__ import annotations -from typing import Any - import snowflake.connector -from sqlquack_record.constants import QUERY_TAG -from sqlquack_record.errors import RecorderError -from sqlquack_record.models import SessionValue -from sqlquack_record.snowflake._helpers.session import SnowflakeSession +from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.recording.types import SessionFactory, SessionParameters, WarehouseSession +from sqlquack_record.snowflake._helpers.connections import connect_arguments +from sqlquack_record.snowflake.classes.snowflake_session import SnowflakeSession from sqlquack_record.snowflake.models import ConnectionSettings -from sqlquack_record.types import SessionFactory, WarehouseSession def snowflake_sessions(settings: ConnectionSettings) -> SessionFactory: """A factory opening one Snowflake session per set of session parameters.""" - def open_session(parameters: tuple[tuple[str, SessionValue], ...]) -> WarehouseSession: - arguments: dict[str, Any] = { - "session_parameters": {**dict(parameters), "QUERY_TAG": QUERY_TAG}, - "arrow_number_to_decimal": True, - "client_store_temporary_credential": True, - "oauth_enable_refresh_tokens": True, - **dict(settings.overrides), - } - if settings.connection_name is not None: - arguments["connection_name"] = settings.connection_name - if settings.warehouse is not None: - arguments["warehouse"] = settings.warehouse - session: SnowflakeSession = SnowflakeSession(snowflake.connector.connect(**arguments)) + def open_session(parameters: SessionParameters) -> WarehouseSession: + session: SnowflakeSession = SnowflakeSession( + snowflake.connector.connect( + **connect_arguments(settings=settings, parameters=parameters) + ) + ) if session.current_warehouse() is None: session.close() raise RecorderError( diff --git a/recorder/src/sqlquack_record/snowflake/models.py b/recorder/src/sqlquack_record/snowflake/models.py index 58ed65da7..d24f84d38 100644 --- a/recorder/src/sqlquack_record/snowflake/models.py +++ b/recorder/src/sqlquack_record/snowflake/models.py @@ -7,7 +7,7 @@ @dataclass(frozen=True) class ConnectionSettings: - """How to reach Snowflake: a named connection plus explicit overrides, never stored in the repo.""" + """How to reach Snowflake: a named connection plus overrides, never stored in the repo.""" connection_name: str | None warehouse: str | None diff --git a/recorder/tests/recording/__init__.py b/recorder/tests/recording/__init__.py deleted file mode 100644 index 9d6fc64b7..000000000 --- a/recorder/tests/recording/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Recording tests.""" diff --git a/recorder/tests/recording/helpers.py b/recorder/tests/recording/helpers.py deleted file mode 100644 index 4ee13a3a3..000000000 --- a/recorder/tests/recording/helpers.py +++ /dev/null @@ -1,140 +0,0 @@ -"""A fake warehouse session and helpers for recorder tests.""" - -from __future__ import annotations - -import hashlib -import json -from collections.abc import Sequence -from decimal import Decimal -from pathlib import Path - -import pyarrow as pa - -from sqlquack_record.models import ( - ColumnDescription, - PendingCase, - RecordedError, - SessionValue, - StatementResult, -) -from sqlquack_record.recording.main.record_pending import record_pending -from sqlquack_record.types import WarehouseSession - -_COLUMNS: tuple[ColumnDescription, ...] = ( - ColumnDescription("ORDINAL", 0, "FIXED", 1, 0, None, False), - ColumnDescription("VALUE", 0, "FIXED", 38, 2, None, True), -) - - -def fake_table(statement: str) -> pa.Table: - """The rows the fake warehouse returns for a statement.""" - return pa.table( - { - "ORDINAL": pa.array([1, 2], type=pa.int8()), - "VALUE": pa.array([Decimal(len(statement)) / 100, None], type=pa.decimal128(38, 2)), - } - ) - - -class FakeSession: - """Returns scripted results; statements in `failing` raise a recordable SQL error.""" - - def __init__(self, failing: frozenset[str], log: list[str]) -> None: - self._failing: frozenset[str] = failing - self._log: list[str] = log - - def warehouse_version(self) -> str: - return "0.0.0-fake" - - def connector(self) -> str: - return "fake-connector 0" - - def run_batch(self, statements: Sequence[str]) -> list[StatementResult] | None: - self._log.append("batch") - if any(statement in self._failing for statement in statements): - return None - return [self._rows(statement) for statement in statements] - - def run_one(self, statement: str) -> StatementResult: - self._log.append("one") - if statement in self._failing: - return StatementResult( - query_id="fake-error-query", - columns=(), - table=None, - error=RecordedError(errno=100051, sqlstate="22012", message="Division by zero"), - ) - return self._rows(statement) - - def close(self) -> None: - self._log.append("close") - - def _rows(self, statement: str) -> StatementResult: - return StatementResult( - query_id=f"fake-query-{len(statement)}", - columns=_COLUMNS, - table=fake_table(statement), - error=None, - ) - - -def pending_case(statement: str, parameters: tuple[tuple[str, SessionValue], ...]) -> PendingCase: - """A pending case whose hash derives from its statement.""" - return PendingCase( - case_hash=hashlib.sha256(statement.encode("utf-8")).hexdigest(), - case_reference=f"fake.toml#{len(statement)}", - source_dialect="snowflake", - session_parameters=parameters, - source_sql=statement, - duckdb_version="v2.0.0", - ) - - -def record( - directory: Path, - statements: Sequence[str], - failing: frozenset[str], - parameters: Sequence[tuple[tuple[str, SessionValue], ...]], - preexisting: Sequence[str], -) -> tuple[int, int, list[str], list[str]]: - """Record with a fake session; returns counts, the session log and each case's outcome.""" - cases: list[PendingCase] = [ - pending_case(statement, parameters[index % len(parameters)]) - for index, statement in enumerate(statements) - ] - results: Path = directory / "results" - results.mkdir() - for statement in preexisting: - (results / f"{pending_case(statement, ()).case_hash}.json").write_text( - "{}", encoding="utf-8" - ) - log: list[str] = [] - - def open_session(_: tuple[tuple[str, SessionValue], ...]) -> WarehouseSession: - log.append("open") - return FakeSession(failing, log) - - summary = record_pending(cases, results, open_session, 10, lambda: "2026-01-01T00:00:00Z") - outcomes: list[str] = [_outcome(results, case) for case in cases] - return summary.recorded, summary.errors, log, outcomes - - -def _outcome(results: Path, case: PendingCase) -> str: - metadata: dict[str, object] = json.loads( - (results / f"{case.case_hash}.json").read_text(encoding="utf-8") - ) - if not metadata: - return "preexisting" - arrow_path: Path = results / f"{case.case_hash}.arrow" - if metadata["outcome"] == "error": - return "error" if not arrow_path.exists() else "error-with-arrow" - arrow_bytes: bytes = arrow_path.read_bytes() - table: pa.Table = pa.ipc.open_file(pa.BufferReader(arrow_bytes)).read_all() - intact: bool = ( - table.equals(fake_table(case.source_sql)) - and metadata["arrow_sha256"] == hashlib.sha256(arrow_bytes).hexdigest() - and metadata["row_count"] == table.num_rows - and metadata["case_hash"] == case.case_hash - and metadata["warehouse_version"] == "0.0.0-fake" - ) - return "rows" if intact else "rows-altered" diff --git a/scripts/evidence_guard/_helpers/git.py b/scripts/evidence_guard/_helpers/git.py index fa30ca470..1db70f300 100644 --- a/scripts/evidence_guard/_helpers/git.py +++ b/scripts/evidence_guard/_helpers/git.py @@ -5,11 +5,11 @@ import subprocess from pathlib import Path -from scripts.evidence_guard.constants import RESULTS_DIR +from scripts.evidence_guard.constants import GIT_FIELD_SEPARATOR, RESULTS_DIR from scripts.evidence_guard.models import ChangedPath -def changed_results(repository: Path, base: str, head: str) -> list[ChangedPath]: +def changed_results(*, repository: Path, base: str, head: str) -> list[ChangedPath]: """Every change under the results directory between the merge base of `base` and `head`.""" output: str = subprocess.run( ("git", "diff", "--name-status", "--no-renames", f"{base}...{head}", "--", RESULTS_DIR), @@ -18,14 +18,14 @@ def changed_results(repository: Path, base: str, head: str) -> list[ChangedPath] capture_output=True, text=True, ).stdout - return [ - ChangedPath(status=line.split("\t", 1)[0], path=line.split("\t", 1)[1]) - for line in output.splitlines() - if "\t" in line - ] + changes: list[ChangedPath] = [] + for line in output.splitlines(): + status, _, path = line.partition(GIT_FIELD_SEPARATOR) + changes.append(ChangedPath(status=status, path=path)) + return changes -def blob(repository: Path, head: str, path: str) -> bytes | None: +def blob(*, repository: Path, head: str, path: str) -> bytes | None: """The bytes of `path` at `head`, absent when it does not exist there.""" result: subprocess.CompletedProcess[bytes] = subprocess.run( ("git", "show", f"{head}:{path}"), cwd=repository, check=False, capture_output=True diff --git a/scripts/evidence_guard/_helpers/metadata.py b/scripts/evidence_guard/_helpers/metadata.py index ad5845e39..4ac4ebcee 100644 --- a/scripts/evidence_guard/_helpers/metadata.py +++ b/scripts/evidence_guard/_helpers/metadata.py @@ -8,31 +8,36 @@ from scripts.evidence_guard._helpers.git import blob from scripts.evidence_guard.constants import ( + ARROW_SUFFIX, + ERROR_OUTCOME, EVIDENCE_FORMAT, HASH_LENGTH, + HEX_DIGITS, METADATA_KEYS, + METADATA_SUFFIX, RECORDER_PREFIX, RESULTS_DIR, + ROWS_OUTCOME, ) -def added_result_problems(repository: Path, head: str, path: str) -> list[str]: +def added_result_problems(*, repository: Path, head: str, path: str) -> list[str]: """Why the added file `path` is not a valid new result, if it is not.""" name: PurePosixPath = PurePosixPath(path) stem: str = name.stem - if len(stem) != HASH_LENGTH or any(character not in "0123456789abcdef" for character in stem): + if len(stem) != HASH_LENGTH or not set(stem) <= HEX_DIGITS: return [f"{path}: result files are named by a lower-case SHA-256 case hash"] - if name.suffix == ".arrow": - if blob(repository, head, f"{RESULTS_DIR}/{stem}.json") is None: - return [f"{path}: an Arrow file needs its metadata file"] - return [] - if name.suffix != ".json": - return [f"{path}: only .json and .arrow result files are allowed"] - return _metadata_problems(repository, head, path, stem) + sibling: str = f"{RESULTS_DIR}/{stem}{METADATA_SUFFIX}" + if name.suffix == ARROW_SUFFIX: + present: bool = blob(repository=repository, head=head, path=sibling) is not None + return [] if present else [f"{path}: an Arrow file needs its metadata file"] + if name.suffix != METADATA_SUFFIX: + return [f"{path}: only {METADATA_SUFFIX} and {ARROW_SUFFIX} result files are allowed"] + return _metadata_problems(repository=repository, head=head, path=path, stem=stem) -def _metadata_problems(repository: Path, head: str, path: str, stem: str) -> list[str]: - content: bytes | None = blob(repository, head, path) +def _metadata_problems(*, repository: Path, head: str, path: str, stem: str) -> list[str]: + content: bytes | None = blob(repository=repository, head=head, path=path) try: metadata: object = json.loads(content or b"") except json.JSONDecodeError: @@ -44,10 +49,12 @@ def _metadata_problems(repository: Path, head: str, path: str, stem: str) -> lis problems.append(f"{path}: wrong format or case hash") if not str(metadata["recorder"]).startswith(RECORDER_PREFIX): problems.append(f"{path}: not written by sqlquack-record") - arrow: bytes | None = blob(repository, head, f"{RESULTS_DIR}/{stem}.arrow") - if metadata["outcome"] == "rows": + arrow: bytes | None = blob( + repository=repository, head=head, path=f"{RESULTS_DIR}/{stem}{ARROW_SUFFIX}" + ) + if metadata["outcome"] == ROWS_OUTCOME: if arrow is None or metadata["arrow_sha256"] != hashlib.sha256(arrow).hexdigest(): problems.append(f"{path}: the Arrow file is missing or does not match arrow_sha256") - elif metadata["outcome"] != "error" or arrow is not None or metadata["error"] is None: + elif metadata["outcome"] != ERROR_OUTCOME or arrow is not None or metadata["error"] is None: problems.append(f"{path}: an error outcome needs an error and no Arrow file") return problems diff --git a/scripts/evidence_guard/constants.py b/scripts/evidence_guard/constants.py index 4c2409880..be0e18fee 100644 --- a/scripts/evidence_guard/constants.py +++ b/scripts/evidence_guard/constants.py @@ -25,3 +25,10 @@ } ) HASH_LENGTH: int = 64 +ADDED_STATUS: str = "A" +ARROW_SUFFIX: str = ".arrow" +METADATA_SUFFIX: str = ".json" +ROWS_OUTCOME: str = "rows" +ERROR_OUTCOME: str = "error" +HEX_DIGITS: frozenset[str] = frozenset("0123456789abcdef") +GIT_FIELD_SEPARATOR: str = "\t" diff --git a/scripts/evidence_guard/main/check.py b/scripts/evidence_guard/main/check.py index f59c833dd..ea508edbd 100644 --- a/scripts/evidence_guard/main/check.py +++ b/scripts/evidence_guard/main/check.py @@ -8,7 +8,7 @@ from scripts.evidence_guard._helpers.git import changed_results from scripts.evidence_guard._helpers.metadata import added_result_problems -from scripts.evidence_guard.constants import UNGUARDED_FILES +from scripts.evidence_guard.constants import ADDED_STATUS, UNGUARDED_FILES from scripts.evidence_guard.models import ChangedPath @@ -19,22 +19,28 @@ def run_evidence_guard(argv: list[str] | None = None) -> int: parser.add_argument("--head", default="HEAD") parser.add_argument("--repository", type=Path, default=Path.cwd()) arguments: argparse.Namespace = parser.parse_args(argv) - changes: list[ChangedPath] = changed_results( - arguments.repository, arguments.base, arguments.head - ) + changes: list[ChangedPath] = [ + change + for change in changed_results( + repository=arguments.repository, base=arguments.base, head=arguments.head + ) + if PurePosixPath(change.path).name not in UNGUARDED_FILES + ] problems: list[str] = [] for change in changes: - if PurePosixPath(change.path).name in UNGUARDED_FILES: - continue - if change.status != "A": + if change.status != ADDED_STATUS: problems.append( f"{change.path}: recorded results are append-only (status {change.status})" ) continue - problems.extend(added_result_problems(arguments.repository, arguments.head, change.path)) + problems.extend( + added_result_problems( + repository=arguments.repository, head=arguments.head, path=change.path + ) + ) for problem in problems: print(f"evidence guard: {problem}", file=sys.stderr) - added: int = sum(change.status == "A" for change in changes) + added: int = sum(change.status == ADDED_STATUS for change in changes) print( f"evidence guard: {'FAILED' if problems else 'passed'}, {added} added result files, " f"{len(problems)} problems", diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py deleted file mode 100644 index 7077a39f4..000000000 --- a/tests/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tooling tests.""" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 000000000..e0310a01e --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests.""" diff --git a/recorder/tests/__init__.py b/tests/unit/recorder/__init__.py similarity index 100% rename from recorder/tests/__init__.py rename to tests/unit/recorder/__init__.py diff --git a/tests/unit/recorder/src/__init__.py b/tests/unit/recorder/src/__init__.py new file mode 100644 index 000000000..9a7ab5981 --- /dev/null +++ b/tests/unit/recorder/src/__init__.py @@ -0,0 +1 @@ +"""Recorder tests.""" diff --git a/tests/unit/recorder/src/sqlquack_record/__init__.py b/tests/unit/recorder/src/sqlquack_record/__init__.py new file mode 100644 index 000000000..9a7ab5981 --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/__init__.py @@ -0,0 +1 @@ +"""Recorder tests.""" diff --git a/tests/unit/recorder/src/sqlquack_record/pending/__init__.py b/tests/unit/recorder/src/sqlquack_record/pending/__init__.py new file mode 100644 index 000000000..ebe80acec --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/pending/__init__.py @@ -0,0 +1 @@ +"""Pending file tests.""" diff --git a/tests/unit/recorder/src/sqlquack_record/pending/_test_types.py b/tests/unit/recorder/src/sqlquack_record/pending/_test_types.py new file mode 100644 index 000000000..56244e802 --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/pending/_test_types.py @@ -0,0 +1,14 @@ +"""Test types for loading pending case files.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LoadPendingTestCase: + description: str + file_name: str + replace: str + with_text: str + expected_references: tuple[str, ...] | None diff --git a/tests/unit/recorder/src/sqlquack_record/pending/helpers.py b/tests/unit/recorder/src/sqlquack_record/pending/helpers.py new file mode 100644 index 000000000..2d79f534a --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/pending/helpers.py @@ -0,0 +1,33 @@ +"""Pending case files in the format `sqlquack pending` writes.""" + +from __future__ import annotations + +from pathlib import Path + +from sqlquack_record.pending.main.load_pending import load_pending +from sqlquack_record.recording.exceptions import RecorderError + +CASE_HASH: str = "29c5ebb55926dab5055ab46ba08e010c775eab2da0124ac636ed33d87f56376c" +PENDING_FILE: str = """{ + "format": 1, + "case_hash": "29c5ebb55926dab5055ab46ba08e010c775eab2da0124ac636ed33d87f56376c", + "case_reference": "numeric/addition.toml#mixed_scales", + "source_dialect": "snowflake", + "session_parameters": { + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_START": 0 + }, + "source_sql": "SELECT 1 AS ordinal", + "duckdb_version": "v2.0.0" +} +""" + + +def references(*, directory: Path, file_name: str, text: str) -> tuple[str, ...] | None: + """The case references loaded from one pending file, or `None` when it is rejected.""" + (directory / file_name).write_text(text, encoding="utf-8") + try: + return tuple(case.case_reference for case in load_pending(directory)) + except RecorderError: + return None diff --git a/tests/unit/recorder/src/sqlquack_record/pending/test_load_pending.py b/tests/unit/recorder/src/sqlquack_record/pending/test_load_pending.py new file mode 100644 index 000000000..83a992755 --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/pending/test_load_pending.py @@ -0,0 +1,60 @@ +"""Loading pending case files.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.unit.recorder.src.sqlquack_record.pending._test_types import LoadPendingTestCase +from tests.unit.recorder.src.sqlquack_record.pending.helpers import ( + CASE_HASH, + PENDING_FILE, + references, +) + + +@pytest.mark.parametrize( + "test_case", + ( + LoadPendingTestCase( + description="a file as sqlquack pending writes it loads", + file_name=f"{CASE_HASH}.json", + replace="", + with_text="", + expected_references=("numeric/addition.toml#mixed_scales",), + ), + LoadPendingTestCase( + description="files not named by a case hash are ignored", + file_name="notes.json", + replace="", + with_text="", + expected_references=(), + ), + LoadPendingTestCase( + description="a file whose name and hash differ is rejected", + file_name=f"{'0' * 64}.json", + replace="", + with_text="", + expected_references=None, + ), + LoadPendingTestCase( + description="an unknown key is rejected", + file_name=f"{CASE_HASH}.json", + replace='"format": 1,', + with_text='"format": 1, "tolerance": 1,', + expected_references=None, + ), + ), + ids=lambda case: case.description, +) +def test_given_pending_file_when_loading_then_only_exact_case_files_load( + test_case: LoadPendingTestCase, tmp_path: Path +) -> None: + loaded: tuple[str, ...] | None = references( + directory=tmp_path, + file_name=test_case.file_name, + text=PENDING_FILE.replace(test_case.replace, test_case.with_text, 1), + ) + + assert loaded == test_case.expected_references diff --git a/tests/unit/recorder/src/sqlquack_record/recording/__init__.py b/tests/unit/recorder/src/sqlquack_record/recording/__init__.py new file mode 100644 index 000000000..adfbfeb3c --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/recording/__init__.py @@ -0,0 +1 @@ +"""Recording unit tests.""" diff --git a/recorder/tests/recording/_test_types.py b/tests/unit/recorder/src/sqlquack_record/recording/_test_types.py similarity index 53% rename from recorder/tests/recording/_test_types.py rename to tests/unit/recorder/src/sqlquack_record/recording/_test_types.py index 235f2e98e..3119b0d41 100644 --- a/recorder/tests/recording/_test_types.py +++ b/tests/unit/recorder/src/sqlquack_record/recording/_test_types.py @@ -4,16 +4,20 @@ from dataclasses import dataclass +# What was stored for one case: the outcome, whether the Arrow file matches the metadata hash, +# and whether the stored rows equal the warehouse's Arrow table. +type RecordedFile = tuple[str, bool, bool] + @dataclass(frozen=True) class RecordTestCase: description: str statements: tuple[str, ...] failing: frozenset[str] + batch_fails: bool parameters: tuple[tuple[tuple[str, bool | int | str], ...], ...] preexisting: tuple[str, ...] expected_recorded: int - expected_errors: int - expected_sessions: int - expected_batches: int - expected_outcomes: tuple[str, ...] + expected_failures: int + expected_log: tuple[str, ...] + expected_files: tuple[RecordedFile, ...] diff --git a/tests/unit/recorder/src/sqlquack_record/recording/helpers.py b/tests/unit/recorder/src/sqlquack_record/recording/helpers.py new file mode 100644 index 000000000..c4abe43fb --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/recording/helpers.py @@ -0,0 +1,153 @@ +"""A fake warehouse session and helpers for recorder tests.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Sequence +from decimal import Decimal +from pathlib import Path + +import pyarrow as pa + +from sqlquack_record.recording.main.record_pending import record_pending +from sqlquack_record.recording.models import ( + ColumnDescription, + PendingCase, + RecordSummary, + StatementResult, + WarehouseFailure, +) +from sqlquack_record.recording.types import SessionParameters, WarehouseSession +from tests.unit.recorder.src.sqlquack_record.recording._test_types import RecordedFile + +_COLUMNS: tuple[ColumnDescription, ...] = ( + ColumnDescription("ORDINAL", 0, "FIXED", 1, 0, None, False), + ColumnDescription("VALUE", 0, "FIXED", 38, 2, None, True), +) +_FAILURE: StatementResult = StatementResult( + query_id="fake-error-query", + columns=(), + table=None, + failure=WarehouseFailure(errno=100051, sqlstate="22012", message="Division by zero"), +) + + +def fake_table(statement: str) -> pa.Table: + """The rows the fake warehouse returns for a statement.""" + return pa.table( + { + "ORDINAL": pa.array([1, 2], type=pa.int8()), + "VALUE": pa.array([Decimal(len(statement)) / 100, None], type=pa.decimal128(38, 2)), + } + ) + + +def _rows(statement: str) -> StatementResult: + return StatementResult( + query_id=f"fake-query-{len(statement)}", + columns=_COLUMNS, + table=fake_table(statement), + failure=None, + ) + + +class FakeSession: + """Answers from scripted responses; a failing batch returns `None` like a SQL error does.""" + + def __init__( + self, *, responses: dict[str, StatementResult], batch_fails: bool, log: list[str] + ) -> None: + self._responses: dict[str, StatementResult] = responses + self._batch_fails: bool = batch_fails + self._log: list[str] = log + + def warehouse_version(self) -> str: + return "0.0.0-fake" + + def connector(self) -> str: + return "fake-connector 0" + + def run_batch(self, statements: Sequence[str]) -> list[StatementResult] | None: + self._log.append("batch") + answers: dict[bool, list[StatementResult] | None] = { + False: [self._responses[statement] for statement in statements], + True: None, + } + return answers[self._batch_fails] + + def run_one(self, statement: str) -> StatementResult: + self._log.append("one") + return self._responses[statement] + + def close(self) -> None: + self._log.append("close") + + +def _pending_case(*, statement: str, parameters: SessionParameters) -> PendingCase: + return PendingCase( + case_hash=hashlib.sha256(statement.encode("utf-8")).hexdigest(), + case_reference=f"fake.toml#{len(statement)}", + source_dialect="snowflake", + session_parameters=parameters, + source_sql=statement, + duckdb_version="v2.0.0", + ) + + +def _recorded_file(*, results: Path, case: PendingCase) -> RecordedFile: + metadata: dict[str, object] = json.loads( + (results / f"{case.case_hash}.json").read_text(encoding="utf-8") + ) + arrow_files: list[bytes] = [ + path.read_bytes() for path in results.glob(f"{case.case_hash}.arrow") + ] + claimed: list[object] = [metadata.get("arrow_sha256")] + return ( + str(metadata.get("outcome", "preexisting")), + [hashlib.sha256(data).hexdigest() for data in arrow_files] == list(filter(None, claimed)), + all( + pa.ipc.open_file(pa.BufferReader(data)).read_all().equals(fake_table(case.source_sql)) + for data in arrow_files + ), + ) + + +def record( + *, + directory: Path, + statements: Sequence[str], + failing: frozenset[str], + batch_fails: bool, + parameters: Sequence[SessionParameters], + preexisting: Sequence[str], +) -> tuple[int, int, list[str], list[RecordedFile]]: + """Record with a fake session; returns counts, the session log and what each case stored.""" + cases: list[PendingCase] = [ + _pending_case(statement=statement, parameters=parameters[index % len(parameters)]) + for index, statement in enumerate(statements) + ] + results: Path = directory / "results" + results.mkdir() + for statement in preexisting: + stored: PendingCase = _pending_case(statement=statement, parameters=()) + (results / f"{stored.case_hash}.json").write_text("{}", encoding="utf-8") + responses: dict[str, StatementResult] = { + **{statement: _rows(statement) for statement in statements}, + **{statement: _FAILURE for statement in failing}, + } + log: list[str] = [] + + def open_session(_: SessionParameters) -> WarehouseSession: + log.append("open") + return FakeSession(responses=responses, batch_fails=batch_fails, log=log) + + summary: RecordSummary = record_pending( + cases=cases, + results=results, + open_session=open_session, + batch_size=10, + clock=lambda: "2026-01-01T00:00:00Z", + ) + files: list[RecordedFile] = [_recorded_file(results=results, case=case) for case in cases] + return summary.recorded, summary.failures, log, files diff --git a/recorder/tests/recording/test_record_pending.py b/tests/unit/recorder/src/sqlquack_record/recording/test_record_pending.py similarity index 54% rename from recorder/tests/recording/test_record_pending.py rename to tests/unit/recorder/src/sqlquack_record/recording/test_record_pending.py index e461d2d2c..a60449ea1 100644 --- a/recorder/tests/recording/test_record_pending.py +++ b/tests/unit/recorder/src/sqlquack_record/recording/test_record_pending.py @@ -6,63 +6,69 @@ import pytest -from recorder.tests.recording._test_types import RecordTestCase -from recorder.tests.recording.helpers import record +from tests.unit.recorder.src.sqlquack_record.recording._test_types import ( + RecordedFile, + RecordTestCase, +) +from tests.unit.recorder.src.sqlquack_record.recording.helpers import record _UTC: tuple[tuple[str, bool | int | str], ...] = (("TIMEZONE", "UTC"),) _WEEKS: tuple[tuple[str, bool | int | str], ...] = (("TIMEZONE", "UTC"), ("WEEK_START", 1)) +_ROWS: RecordedFile = ("rows", True, True) +_ERROR: RecordedFile = ("error", True, True) +_EXISTING: RecordedFile = ("preexisting", True, True) @pytest.mark.parametrize( "test_case", ( RecordTestCase( - description="rows are stored as the connector's Arrow table, unconverted", + description="rows are stored as the connector's Arrow table in one batch, unconverted", statements=("SELECT 1", "SELECT 22"), failing=frozenset(), + batch_fails=False, parameters=(_UTC,), preexisting=(), expected_recorded=2, - expected_errors=0, - expected_sessions=1, - expected_batches=1, - expected_outcomes=("rows", "rows"), + expected_failures=0, + expected_log=("open", "batch", "close"), + expected_files=(_ROWS, _ROWS), ), RecordTestCase( description="a batch with a SQL error is rerun case by case and the error recorded", statements=("SELECT 1", "SELECT 1 / 0"), failing=frozenset({"SELECT 1 / 0"}), + batch_fails=True, parameters=(_UTC,), preexisting=(), expected_recorded=2, - expected_errors=1, - expected_sessions=1, - expected_batches=1, - expected_outcomes=("rows", "error"), + expected_failures=1, + expected_log=("open", "batch", "one", "one", "close"), + expected_files=(_ROWS, _ERROR), ), RecordTestCase( description="an existing result is never re-recorded", statements=("SELECT 1", "SELECT 22"), failing=frozenset(), + batch_fails=False, parameters=(_UTC,), preexisting=("SELECT 1",), expected_recorded=1, - expected_errors=0, - expected_sessions=1, - expected_batches=0, - expected_outcomes=("preexisting", "rows"), + expected_failures=0, + expected_log=("open", "one", "close"), + expected_files=(_EXISTING, _ROWS), ), RecordTestCase( description="each set of session parameters gets its own session", statements=("SELECT 1", "SELECT 22"), failing=frozenset(), + batch_fails=False, parameters=(_UTC, _WEEKS), preexisting=(), expected_recorded=2, - expected_errors=0, - expected_sessions=2, - expected_batches=0, - expected_outcomes=("rows", "rows"), + expected_failures=0, + expected_log=("open", "one", "close", "open", "one", "close"), + expected_files=(_ROWS, _ROWS), ), ), ids=lambda case: case.description, @@ -70,16 +76,16 @@ def test_given_pending_cases_when_recording_then_results_are_stored_once_and_unconverted( test_case: RecordTestCase, tmp_path: Path ) -> None: - recorded, errors, log, outcomes = record( - tmp_path, - test_case.statements, - test_case.failing, - test_case.parameters, - test_case.preexisting, + recorded, failures, log, files = record( + directory=tmp_path, + statements=test_case.statements, + failing=test_case.failing, + batch_fails=test_case.batch_fails, + parameters=test_case.parameters, + preexisting=test_case.preexisting, ) assert recorded == test_case.expected_recorded - assert errors == test_case.expected_errors - assert log.count("open") == test_case.expected_sessions - assert log.count("batch") == test_case.expected_batches - assert tuple(outcomes) == test_case.expected_outcomes + assert failures == test_case.expected_failures + assert tuple(log) == test_case.expected_log + assert tuple(files) == test_case.expected_files diff --git a/tests/unit/recorder/src/sqlquack_record/snowflake/__init__.py b/tests/unit/recorder/src/sqlquack_record/snowflake/__init__.py new file mode 100644 index 000000000..fe7866a21 --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/snowflake/__init__.py @@ -0,0 +1 @@ +"""Snowflake connection tests.""" diff --git a/tests/unit/recorder/src/sqlquack_record/snowflake/_test_types.py b/tests/unit/recorder/src/sqlquack_record/snowflake/_test_types.py new file mode 100644 index 000000000..f8a3064dc --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/snowflake/_test_types.py @@ -0,0 +1,14 @@ +"""Test types for connection arguments.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ConnectArgumentsTestCase: + description: str + connection_name: str | None + warehouse: str | None + overrides: tuple[tuple[str, str], ...] + expected_arguments: dict[str, object] | None diff --git a/tests/unit/recorder/src/sqlquack_record/snowflake/helpers.py b/tests/unit/recorder/src/sqlquack_record/snowflake/helpers.py new file mode 100644 index 000000000..6dd55f3e9 --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/snowflake/helpers.py @@ -0,0 +1,43 @@ +"""Connection arguments against a fake connector configuration.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.snowflake._helpers import connections +from sqlquack_record.snowflake._helpers.connections import connect_arguments +from sqlquack_record.snowflake.models import ConnectionSettings + +FAKE_CONFIGURATION: dict[str, Any] = { + "default_connection_name": "shop", + "connections": { + "shop": { + "account": "example-account", + "authenticator": "oauth_authorization_code", + "warehouse": "SMALL", + "session_parameters": {"TIMEZONE": "America/New_York", "QUERY_TAG": "other"}, + }, + "explicit": {"account": "example-account", "client_store_temporary_credential": False}, + }, +} + + +def arguments_for( + *, + monkeypatch: pytest.MonkeyPatch, + connection_name: str | None, + warehouse: str | None, + overrides: tuple[tuple[str, str], ...], +) -> dict[str, object] | None: + """The connect arguments for case parameters `TIMEZONE=UTC`, or `None` when refused.""" + monkeypatch.setattr(connections, "CONFIG_MANAGER", FAKE_CONFIGURATION) + settings: ConnectionSettings = ConnectionSettings( + connection_name=connection_name, warehouse=warehouse, overrides=overrides + ) + try: + return connect_arguments(settings=settings, parameters=(("TIMEZONE", "UTC"),)) + except RecorderError: + return None diff --git a/tests/unit/recorder/src/sqlquack_record/snowflake/test_connect_arguments.py b/tests/unit/recorder/src/sqlquack_record/snowflake/test_connect_arguments.py new file mode 100644 index 000000000..b88b9d049 --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/snowflake/test_connect_arguments.py @@ -0,0 +1,82 @@ +"""Connection arguments from the connector's configuration and environment overrides.""" + +from __future__ import annotations + +import pytest + +from tests.unit.recorder.src.sqlquack_record.snowflake._test_types import ( + ConnectArgumentsTestCase, +) +from tests.unit.recorder.src.sqlquack_record.snowflake.helpers import arguments_for + +_CASE_SESSION: dict[str, object] = {"TIMEZONE": "UTC", "QUERY_TAG": "sqlquack-record"} + + +@pytest.mark.parametrize( + "test_case", + ( + ConnectArgumentsTestCase( + description="the default connection, with token caching and the case's parameters", + connection_name=None, + warehouse=None, + overrides=(), + expected_arguments={ + "account": "example-account", + "authenticator": "oauth_authorization_code", + "warehouse": "SMALL", + "client_store_temporary_credential": True, + "oauth_enable_refresh_tokens": True, + "session_parameters": _CASE_SESSION, + "arrow_number_to_decimal": True, + }, + ), + ConnectArgumentsTestCase( + description="a named connection keeps its explicit settings; --warehouse wins", + connection_name="explicit", + warehouse="OTHER", + overrides=(), + expected_arguments={ + "account": "example-account", + "warehouse": "OTHER", + "client_store_temporary_credential": False, + "oauth_enable_refresh_tokens": True, + "session_parameters": _CASE_SESSION, + "arrow_number_to_decimal": True, + }, + ), + ConnectArgumentsTestCase( + description="an account from the environment replaces the configured connection", + connection_name=None, + warehouse="SMALL", + overrides=(("account", "env-account"), ("user", "someone")), + expected_arguments={ + "account": "env-account", + "user": "someone", + "warehouse": "SMALL", + "client_store_temporary_credential": True, + "oauth_enable_refresh_tokens": True, + "session_parameters": _CASE_SESSION, + "arrow_number_to_decimal": True, + }, + ), + ConnectArgumentsTestCase( + description="an unknown connection name is refused", + connection_name="missing", + warehouse=None, + overrides=(), + expected_arguments=None, + ), + ), + ids=lambda case: case.description, +) +def test_given_connection_settings_when_connecting_then_arguments_merge_in_order( + test_case: ConnectArgumentsTestCase, monkeypatch: pytest.MonkeyPatch +) -> None: + arguments: dict[str, object] | None = arguments_for( + monkeypatch=monkeypatch, + connection_name=test_case.connection_name, + warehouse=test_case.warehouse, + overrides=test_case.overrides, + ) + + assert arguments == test_case.expected_arguments diff --git a/tests/unit/scripts/__init__.py b/tests/unit/scripts/__init__.py new file mode 100644 index 000000000..517cec422 --- /dev/null +++ b/tests/unit/scripts/__init__.py @@ -0,0 +1 @@ +"""Tooling unit tests.""" diff --git a/tests/scripts/evidence_guard/__init__.py b/tests/unit/scripts/evidence_guard/__init__.py similarity index 100% rename from tests/scripts/evidence_guard/__init__.py rename to tests/unit/scripts/evidence_guard/__init__.py diff --git a/tests/scripts/evidence_guard/_test_types.py b/tests/unit/scripts/evidence_guard/_test_types.py similarity index 65% rename from tests/scripts/evidence_guard/_test_types.py rename to tests/unit/scripts/evidence_guard/_test_types.py index 6fe406cec..67c6ba8e0 100644 --- a/tests/scripts/evidence_guard/_test_types.py +++ b/tests/unit/scripts/evidence_guard/_test_types.py @@ -2,12 +2,16 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path + +type Change = Callable[[Path, str], None] @dataclass(frozen=True) class GuardTestCase: description: str - change: str + change: Change expected_return_code: int expected_fragment: str diff --git a/tests/scripts/evidence_guard/helpers.py b/tests/unit/scripts/evidence_guard/helpers.py similarity index 52% rename from tests/scripts/evidence_guard/helpers.py rename to tests/unit/scripts/evidence_guard/helpers.py index 0089db75b..80c00cf79 100644 --- a/tests/scripts/evidence_guard/helpers.py +++ b/tests/unit/scripts/evidence_guard/helpers.py @@ -1,4 +1,4 @@ -"""A throwaway Git repository with recorded results.""" +"""A throwaway Git repository with recorded results, and the changes applied to it.""" from __future__ import annotations @@ -8,8 +8,10 @@ from pathlib import Path from scripts.evidence_guard.main.check import run_evidence_guard +from tests.unit.scripts.evidence_guard._test_types import Change _RESULTS: str = "evidence/results" +_RECORDER: str = "sqlquack-record 0.1.0" def _git(repository: Path, *arguments: str) -> None: @@ -21,7 +23,7 @@ def _git(repository: Path, *arguments: str) -> None: ) -def _write_result(results: Path, seed: str, recorder: str, arrow: bytes, claimed: bytes) -> str: +def _write_result(*, results: Path, seed: str, recorder: str, arrow: bytes, claimed: bytes) -> str: case_hash: str = hashlib.sha256(seed.encode("utf-8")).hexdigest() (results / f"{case_hash}.arrow").write_bytes(arrow) metadata: dict[str, object] = { @@ -44,24 +46,48 @@ def _write_result(results: Path, seed: str, recorder: str, arrow: bytes, claimed return case_hash -def guard_after(repository: Path, change: str) -> int: +def unchanged(_: Path, __: str) -> None: + """Changes nothing under the results.""" + + +def edit_stored(results: Path, stored: str) -> None: + """Rewrites the stored Arrow file.""" + (results / f"{stored}.arrow").write_bytes(b"other rows") + + +def delete_stored(results: Path, stored: str) -> None: + """Deletes the stored metadata file.""" + (results / f"{stored}.json").unlink() + + +def add_valid(results: Path, _: str) -> None: + """Adds a well-formed result.""" + _write_result(results=results, seed="new", recorder=_RECORDER, arrow=b"new", claimed=b"new") + + +def add_mismatched(results: Path, _: str) -> None: + """Adds a result whose Arrow file does not match its metadata.""" + _write_result(results=results, seed="new", recorder=_RECORDER, arrow=b"new", claimed=b"old") + + +def add_stand_in(results: Path, _: str) -> None: + """Adds a result that the recorder did not write.""" + _write_result( + results=results, seed="new", recorder="test fixture", arrow=b"new", claimed=b"new" + ) + + +def guard_after(*, repository: Path, change: Change) -> int: """Commit one recorded result as the base, apply `change` on top, and run the guard.""" results: Path = repository / _RESULTS results.mkdir(parents=True) _git(repository, "init", "--quiet", "--initial-branch=main") - stored: str = _write_result(results, "stored", "sqlquack-record 0.1.0", b"rows", b"rows") + stored: str = _write_result( + results=results, seed="stored", recorder=_RECORDER, arrow=b"rows", claimed=b"rows" + ) _git(repository, "add", "-A") _git(repository, "commit", "--quiet", "-m", "base") - if change == "edit": - (results / f"{stored}.arrow").write_bytes(b"other rows") - elif change == "delete": - (results / f"{stored}.json").unlink() - elif change == "add": - _write_result(results, "new", "sqlquack-record 0.1.0", b"new rows", b"new rows") - elif change == "add-mismatched": - _write_result(results, "new", "sqlquack-record 0.1.0", b"new rows", b"claimed rows") - elif change == "add-stand-in": - _write_result(results, "new", "test fixture", b"new rows", b"new rows") + change(results, stored) _git(repository, "add", "-A") _git(repository, "commit", "--quiet", "--allow-empty", "-m", "change") return run_evidence_guard(["--base", "HEAD~1", "--repository", str(repository)]) diff --git a/tests/scripts/evidence_guard/test_check.py b/tests/unit/scripts/evidence_guard/test_check.py similarity index 76% rename from tests/scripts/evidence_guard/test_check.py rename to tests/unit/scripts/evidence_guard/test_check.py index 4e46cf3a1..58e295b56 100644 --- a/tests/scripts/evidence_guard/test_check.py +++ b/tests/unit/scripts/evidence_guard/test_check.py @@ -6,8 +6,16 @@ import pytest -from tests.scripts.evidence_guard._test_types import GuardTestCase -from tests.scripts.evidence_guard.helpers import guard_after +from tests.unit.scripts.evidence_guard._test_types import GuardTestCase +from tests.unit.scripts.evidence_guard.helpers import ( + add_mismatched, + add_stand_in, + add_valid, + delete_stored, + edit_stored, + guard_after, + unchanged, +) @pytest.mark.parametrize( @@ -15,37 +23,37 @@ ( GuardTestCase( description="an unrelated change passes", - change="none", + change=unchanged, expected_return_code=0, expected_fragment="passed, 0 added", ), GuardTestCase( description="a new recorded result passes", - change="add", + change=add_valid, expected_return_code=0, expected_fragment="passed, 2 added", ), GuardTestCase( description="an edited result is rejected", - change="edit", + change=edit_stored, expected_return_code=1, expected_fragment="append-only (status M)", ), GuardTestCase( description="a deleted result is rejected", - change="delete", + change=delete_stored, expected_return_code=1, expected_fragment="append-only (status D)", ), GuardTestCase( description="a new result whose Arrow file does not match its metadata is rejected", - change="add-mismatched", + change=add_mismatched, expected_return_code=1, expected_fragment="does not match arrow_sha256", ), GuardTestCase( description="a result not written by the recorder is rejected", - change="add-stand-in", + change=add_stand_in, expected_return_code=1, expected_fragment="not written by sqlquack-record", ), @@ -55,7 +63,7 @@ def test_given_result_change_when_guarding_then_only_valid_additions_pass( test_case: GuardTestCase, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - return_code: int = guard_after(tmp_path, test_case.change) + return_code: int = guard_after(repository=tmp_path, change=test_case.change) assert return_code == test_case.expected_return_code assert test_case.expected_fragment in capsys.readouterr().err diff --git a/uv.lock b/uv.lock index 0c89a3589..9b4b19373 100644 --- a/uv.lock +++ b/uv.lock @@ -342,6 +342,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fensu" version = "0.27.0" @@ -601,6 +610,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -785,10 +807,13 @@ source = { virtual = "." } dev = [ { name = "fensu" }, { name = "pytest" }, + { name = "pytest-xdist" }, { name = "ruff" }, - { name = "sqlquack-record" }, { name = "ty" }, ] +recorder = [ + { name = "sqlquack-record" }, +] [package.metadata] @@ -796,10 +821,11 @@ dev = [ dev = [ { name = "fensu", specifier = ">=0.27.0" }, { name = "pytest", specifier = ">=8.4" }, + { name = "pytest-xdist", specifier = ">=3.8" }, { name = "ruff", specifier = ">=0.12.0" }, - { name = "sqlquack-record", editable = "recorder" }, { name = "ty", specifier = ">=0.0.1a19" }, ] +recorder = [{ name = "sqlquack-record", editable = "recorder" }] [[package]] name = "tomlkit" From 44f024d421600fbdce7e236ec242fdc42e8da849 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:34:23 +0100 Subject: [PATCH 06/11] docs: evidence workflow, recorder settings and integrity --- AGENTS.md | 3 +++ README.md | 24 +++++++++++++++-- docs/architecture.md | 62 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81d6116fb..e34e091aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,9 @@ - Everything that could be weakened to make a failing case pass (comparator tolerances, required edge-case inputs, known divergences) lives in one reviewed policy file. There are no per-case or per-rule overrides elsewhere. +- Tests that need a result use DuckDB stand-ins written to temporary directories and marked as + test fixtures. Never commit a stand-in under `evidence/results`; the evidence guard rejects any + result the recorder did not write. - When a case fails, either fix the rule or make it refuse. Never widen a rule to raise the conversion rate. diff --git a/README.md b/README.md index be4afb2dc..f5d20c1fb 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,34 @@ Each `.sql` file is one query, or one model compiled by [SQLBuild](https://github.com/chio-labs/sqlbuild); SQLBuild's reference calls are recognised. The report contains counts and construct names only, never SQL text or file names. +## Evidence + +Every rule is verified against results recorded on Snowflake and replayed on DuckDB: + +```console +$ cargo run -p sqlquack -- pending # write pending case files +$ uv run sqlquack-record # maintainers only: record them on Snowflake +$ cargo run -p sqlquack -- verify # replay recorded cases on DuckDB and compare +``` + +The recorder uses the Snowflake connector's own configuration: the connection named by +`--connection` or `SQLQUACK_SNOWFLAKE_CONNECTION`, otherwise the connector's default connection +in `~/.snowflake/connections.toml`. `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_USER`, `SNOWFLAKE_ROLE`, +`SNOWFLAKE_AUTHENTICATOR` and `SNOWFLAKE_HOST` override it, and `--warehouse` or +`SQLQUACK_SNOWFLAKE_WAREHOUSE` selects the warehouse. Tokens are cached, so an OAuth +authorization-code login is interactive only when the cached token has expired. + +The policy that governs comparison lives in [sqlquack.toml](sqlquack.toml). + ## How it works See [docs/architecture.md](docs/architecture.md). ## Development -`make check-ci` runs every static gate (rustfmt, clippy, fensu, ruff and ty) and `make test` runs -the tests. See [AGENTS.md](AGENTS.md) for the rules every change follows. +`make check-ci` runs every static gate (rustfmt, clippy, fensu, ruff, ty and the append-only +evidence guard) and `make test` runs the tests. CI runs both with `RECORDER=0`, which leaves the +recorder out. See [AGENTS.md](AGENTS.md) for the rules every change follows. ## License diff --git a/docs/architecture.md b/docs/architecture.md index d59984355..96c693f8f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,8 +29,16 @@ a parameter of every entry point; Snowflake is the only one implemented. | `sqlquack-snowflake` | The Snowflake typing layer (empty). Polyglot leaves many Snowflake expressions untyped, so exact types (`NUMBER(p, s)`, timestamp kinds, semi-structured values) are computed here. | | `sqlquack-rules` | The rule catalogue and declared refusals (empty). | | `sqlquack-duckdb` | DuckDB building blocks shared by rules (empty). | -| `sqlquack-compare` | The strict comparator for recorded results (stub). | -| `sqlquack` | The `sqlquack` command line and the library entry points: the pipeline and the census. | +| `sqlquack-policy` | The policy file `sqlquack.toml`: schema, validation and type-pattern matching. | +| `sqlquack-compare` | The strict comparator between a recorded result and a DuckDB replay or a fresh recording. | +| `sqlquack-evidence` | Cases, case hashes, pending files, stored results, DuckDB replay, verification and audits. | +| `sqlquack` | The `sqlquack` command line and the library entry points: the pipeline, the census and the evidence commands. | + +DuckDB is pinned exactly (`duckdb = "=1.20000.0"`, DuckDB v2.0.0). Its build script downloads the +matching prebuilt `libduckdb`, so binaries are run with `cargo run`, which sets the library path. + +The recorder, `sqlquack-record`, is a separate Python package under `recorder/`. Rust and Python +share files only. Polyglot is pinned to one revision with `default-features = false`. Its `transpile` feature stays off: none of its own conversions are used. @@ -92,3 +100,53 @@ placeholders before parsing. The report holds counts and construct keys only. Recorded warehouse results are the only arbiter of correctness. Recording runs outside CI, through the maintainer; results are append-only and named by the hash of their case, and everything that could be weakened to make a failing case pass lives in one reviewed policy file. + +### Cases + +Case files live under `cases//.toml`. Each file names an edge-case category +from the policy, the construct it exercises and a session profile, then lists cases. A case has +typed input columns, input rows written as source literals, and either an `expression` over the +columns or a `query` over the `input` relation with a declared row order (`ordered` or `bag`). +Every case carries tags, and between them a file's cases must cover every input its category +requires. + +A case expands to one source statement: the input rows as a `WITH input AS (...)` of casts, then +the expression (`SELECT AS value FROM input ORDER BY ordinal`) or the query. + +### Hashes + +The case hash is the SHA-256 of the canonical JSON of the evidence format version, the source +dialect, the session profile's Snowflake parameters and the expanded source SQL. Descriptions, +tags and file locations are not hashed. Any edit that can change the result changes the hash, so +it becomes a new case with a new result. + +### Flow + +1. `sqlquack pending` writes `evidence/pending/.json` for every case without a result. +2. The maintainer runs `sqlquack-record`, which runs the pending SQL in batches, one Snowflake + session per set of session parameters, and writes `evidence/results/.arrow` (the + connector's Arrow result, unconverted) and `.json` (query ID, warehouse, connector and + DuckDB versions, recording time, column descriptions, row count and the Arrow file's SHA-256). + A SQL error is recorded as the outcome instead; session and connection errors stop the run + without writing anything. Existing results are never overwritten. +3. `sqlquack verify` converts each recorded case, replays it on DuckDB in a fresh in-memory + database under the profile's time zone and the policy's NULL order, and compares. A refusal is + always acceptable; a difference fails. +4. `sqlquack audit --sample 10%` writes pending files for a reproducible sample of recorded cases + into `evidence/audit/pending`; once recorded into `evidence/audit/results`, + `sqlquack audit --compare` checks that the fresh results equal the stored ones exactly. + +### Comparison + +Values are decoded into one canonical form: exact decimals with trailing zeros removed, so an +integer and a `DECIMAL(38,0)` with the same value are equal; floats bitwise, with every NaN one +value; dates in days; times and timestamps in nanoseconds. NULL equals only NULL. Column names +match case-insensitively by default, because Snowflake upper-cases unquoted names. A pair of +column types must be admitted by a declared type equality, such as `NUMBER(p,s)` = `DECIMAL(p,s)`. +Audits compare two recordings and allow no tolerance at all. + +### Integrity + +CI runs `make evidence-guard`: any modified or deleted file under `evidence/results` fails, and an +added result must be named by a case hash, have exactly the recorder's metadata keys, name +`sqlquack-record` as its recorder and carry an Arrow file matching its hash. From 5a514315c6f3e49fb0a2286967ac6d40f8e4ac0c Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:34:30 +0100 Subject: [PATCH 07/11] test: neutral stand-in column name --- crates/sqlquack-evidence/src/verify/tests/helpers.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/sqlquack-evidence/src/verify/tests/helpers.rs b/crates/sqlquack-evidence/src/verify/tests/helpers.rs index e309a55b6..8b4a6e228 100644 --- a/crates/sqlquack-evidence/src/verify/tests/helpers.rs +++ b/crates/sqlquack-evidence/src/verify/tests/helpers.rs @@ -75,9 +75,9 @@ tags = ["null"] [[case]] id = "sorted_bag" description = "Values compared as a bag." -columns = [{ name = "price", type = "DECIMAL(10,2)" }] +columns = [{ name = "amount", type = "DECIMAL(10,2)" }] rows = [["3.00"], ["1.00"]] -query = "SELECT price FROM input" +query = "SELECT amount FROM input" order = "bag" tags = [] "#; From f2e2809ffb2f2c98662d1fc14eabe71d20e23c0e Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:37:23 +0100 Subject: [PATCH 08/11] fix: rerun a batch case by case when Snowflake wraps a statement error --- docs/architecture.md | 4 +- .../sqlquack_record/recording/constants.py | 1 + .../snowflake/_helpers/cursors.py | 11 +- .../snowflake/classes/snowflake_session.py | 8 +- .../sqlquack_record/snowflake/_test_types.py | 11 ++ .../src/sqlquack_record/snowflake/helpers.py | 136 +++++++++++++++++- .../snowflake/test_snowflake_session.py | 75 ++++++++++ 7 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 tests/unit/recorder/src/sqlquack_record/snowflake/test_snowflake_session.py diff --git a/docs/architecture.md b/docs/architecture.md index 96c693f8f..445cbe255 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,7 +128,9 @@ it becomes a new case with a new result. connector's Arrow result, unconverted) and `.json` (query ID, warehouse, connector and DuckDB versions, recording time, column descriptions, row count and the Arrow file's SHA-256). A SQL error is recorded as the outcome instead; session and connection errors stop the run - without writing anything. Existing results are never overwritten. + without writing anything. When any statement of a batch fails, Snowflake reports the whole + request as error 100132 (SQLSTATE P0000), so the batch's cases are rerun one at a time and each + real error is classified on its own. Existing results are never overwritten. 3. `sqlquack verify` converts each recorded case, replays it on DuckDB in a fresh in-memory database under the profile's time zone and the policy's NULL order, and compares. A refusal is always acceptable; a difference fails. diff --git a/recorder/src/sqlquack_record/recording/constants.py b/recorder/src/sqlquack_record/recording/constants.py index 2d3a035c8..705fb955e 100644 --- a/recorder/src/sqlquack_record/recording/constants.py +++ b/recorder/src/sqlquack_record/recording/constants.py @@ -41,3 +41,4 @@ "client_store_temporary_credential": True, "oauth_enable_refresh_tokens": True, } +MULTI_STATEMENT_FAILURE: tuple[int, str] = (100132, "P0000") diff --git a/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py b/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py index 458bd0155..4d7138284 100644 --- a/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py +++ b/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py @@ -7,7 +7,10 @@ from snowflake.connector.constants import FIELD_ID_TO_NAME from snowflake.connector.errors import ProgrammingError -from sqlquack_record.recording.constants import RECORDABLE_SQLSTATE_CLASSES +from sqlquack_record.recording.constants import ( + MULTI_STATEMENT_FAILURE, + RECORDABLE_SQLSTATE_CLASSES, +) from sqlquack_record.recording.exceptions import RecorderError from sqlquack_record.recording.models import ColumnDescription, StatementResult @@ -32,6 +35,12 @@ def require_recordable(error: ProgrammingError) -> None: ) from error +def require_batch_recordable(error: ProgrammingError) -> None: + """Accept a multi-statement wrapper error as a batch failure; otherwise `require_recordable`.""" + if (error.errno, error.sqlstate) != MULTI_STATEMENT_FAILURE: + require_recordable(error) + + def _describe(column: Any) -> ColumnDescription: return ColumnDescription( name=str(column.name), diff --git a/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py b/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py index dbb2fe091..8863c33f5 100644 --- a/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py +++ b/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py @@ -10,7 +10,11 @@ from sqlquack_record.recording.exceptions import RecorderError from sqlquack_record.recording.models import StatementResult, WarehouseFailure -from sqlquack_record.snowflake._helpers.cursors import fetch, require_recordable +from sqlquack_record.snowflake._helpers.cursors import ( + fetch, + require_batch_recordable, + require_recordable, +) class SnowflakeSession: @@ -44,7 +48,7 @@ def run_batch(self, statements: Sequence[str]) -> list[StatementResult] | None: raise RecorderError("the warehouse returned fewer results than statements") results.append(fetch(cursor)) except ProgrammingError as error: - require_recordable(error) + require_batch_recordable(error) return None finally: cursor.close() diff --git a/tests/unit/recorder/src/sqlquack_record/snowflake/_test_types.py b/tests/unit/recorder/src/sqlquack_record/snowflake/_test_types.py index f8a3064dc..6f6a57d55 100644 --- a/tests/unit/recorder/src/sqlquack_record/snowflake/_test_types.py +++ b/tests/unit/recorder/src/sqlquack_record/snowflake/_test_types.py @@ -12,3 +12,14 @@ class ConnectArgumentsTestCase: warehouse: str | None overrides: tuple[tuple[str, str], ...] expected_arguments: dict[str, object] | None + + +@dataclass(frozen=True) +class BatchRecordingTestCase: + description: str + statements: tuple[str, ...] + batch_error: tuple[int, str] | None + statement_errors: tuple[tuple[str, int, str], ...] + expected_outcomes: tuple[str, ...] | None + expected_single_runs: tuple[str, ...] + expected_files_written: int diff --git a/tests/unit/recorder/src/sqlquack_record/snowflake/helpers.py b/tests/unit/recorder/src/sqlquack_record/snowflake/helpers.py index 6dd55f3e9..ec9ad3d64 100644 --- a/tests/unit/recorder/src/sqlquack_record/snowflake/helpers.py +++ b/tests/unit/recorder/src/sqlquack_record/snowflake/helpers.py @@ -1,14 +1,27 @@ -"""Connection arguments against a fake connector configuration.""" +"""Connection arguments against a fake configuration, and a fake connection for sessions.""" from __future__ import annotations +import hashlib +import json +from collections import defaultdict +from collections.abc import Callable, Sequence +from functools import partial +from pathlib import Path +from types import SimpleNamespace from typing import Any +import pyarrow as pa import pytest +from snowflake.connector.errors import ProgrammingError from sqlquack_record.recording.exceptions import RecorderError +from sqlquack_record.recording.main.record_pending import record_pending +from sqlquack_record.recording.models import PendingCase +from sqlquack_record.recording.types import SessionParameters, WarehouseSession from sqlquack_record.snowflake._helpers import connections from sqlquack_record.snowflake._helpers.connections import connect_arguments +from sqlquack_record.snowflake.classes.snowflake_session import SnowflakeSession from sqlquack_record.snowflake.models import ConnectionSettings FAKE_CONFIGURATION: dict[str, Any] = { @@ -41,3 +54,124 @@ def arguments_for( return connect_arguments(settings=settings, parameters=(("TIMEZONE", "UTC"),)) except RecorderError: return None + + +_DESCRIPTION: tuple[SimpleNamespace, ...] = ( + SimpleNamespace( + name="VALUE", type_code=0, precision=38, scale=0, internal_size=None, is_nullable=True + ), +) + + +def _succeed() -> None: + """Executes without error.""" + + +def _raise(error: ProgrammingError) -> None: + raise error + + +class FakeCursor: + """Runs each statement by its scripted behaviour and logs it.""" + + def __init__(self, *, behaviours: dict[str, Callable[[], None]], log: list[str]) -> None: + self._behaviours: dict[str, Callable[[], None]] = behaviours + self._log: list[str] = log + self.sfqid: str = "fake-query" + self.description: tuple[SimpleNamespace, ...] = _DESCRIPTION + + def execute(self, statement: str, num_statements: int = 1) -> FakeCursor: + self._log.append(f"{num_statements}:{statement}") + self._behaviours[statement]() + return self + + def fetchone(self) -> tuple[str]: + return ("0.0.0-fake",) + + def fetch_arrow_all(self, force_return_table: bool) -> pa.Table: + return pa.table({"VALUE": pa.array([1], type=pa.int64())}) + + def nextset(self) -> bool: + return True + + def close(self) -> None: + """Nothing to release.""" + + +class FakeConnection: + """Hands out cursors that share one script and one log.""" + + def __init__(self, *, behaviours: dict[str, Callable[[], None]], log: list[str]) -> None: + self._behaviours: dict[str, Callable[[], None]] = behaviours + self._log: list[str] = log + + def cursor(self) -> FakeCursor: + return FakeCursor(behaviours=self._behaviours, log=self._log) + + def close(self) -> None: + """Nothing to release.""" + + +def _error(*, errno: int, sqlstate: str) -> Callable[[], None]: + return partial(_raise, ProgrammingError(msg="scripted", errno=errno, sqlstate=sqlstate)) + + +def record_with_fake_connection( + *, + directory: Path, + statements: Sequence[str], + batch_error: tuple[int, str] | None, + statement_errors: Sequence[tuple[str, int, str]], +) -> tuple[tuple[str, ...] | None, tuple[str, ...], int]: + """Recorded outcomes (`None` when the run stopped), single-statement runs and files written.""" + behaviours: defaultdict[str, Callable[[], None]] = defaultdict(lambda: _succeed) + behaviours.update( + { + ";\n".join(statements): _error(errno=errno, sqlstate=sqlstate) + for errno, sqlstate in filter(None, [batch_error]) + } + ) + behaviours.update( + { + statement: _error(errno=errno, sqlstate=sqlstate) + for statement, errno, sqlstate in statement_errors + } + ) + log: list[str] = [] + + def open_session(_: SessionParameters) -> WarehouseSession: + return SnowflakeSession(FakeConnection(behaviours=behaviours, log=log)) + + cases: list[PendingCase] = [ + PendingCase( + case_hash=hashlib.sha256(statement.encode("utf-8")).hexdigest(), + case_reference=f"fake.toml#{index}", + source_dialect="snowflake", + session_parameters=(), + source_sql=statement, + duckdb_version="v2.0.0", + ) + for index, statement in enumerate(statements) + ] + results: Path = directory / "results" + outcomes: tuple[str, ...] | None = None + try: + record_pending( + cases=cases, + results=results, + open_session=open_session, + batch_size=10, + clock=lambda: "2026-01-01T00:00:00Z", + ) + outcomes = tuple( + str(json.loads((results / f"{case.case_hash}.json").read_text("utf-8"))["outcome"]) + for case in cases + ) + except RecorderError: + pass + single_runs: frozenset[str] = frozenset(f"1:{statement}" for statement in statements) + return ( + outcomes, + tuple(entry.removeprefix("1:") for entry in filter(single_runs.__contains__, log)), + len(list(results.iterdir())), + ) diff --git a/tests/unit/recorder/src/sqlquack_record/snowflake/test_snowflake_session.py b/tests/unit/recorder/src/sqlquack_record/snowflake/test_snowflake_session.py new file mode 100644 index 000000000..c6c27e59f --- /dev/null +++ b/tests/unit/recorder/src/sqlquack_record/snowflake/test_snowflake_session.py @@ -0,0 +1,75 @@ +"""Batch recording through the real Snowflake session over a fake connection.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.unit.recorder.src.sqlquack_record.snowflake._test_types import ( + BatchRecordingTestCase, +) +from tests.unit.recorder.src.sqlquack_record.snowflake.helpers import ( + record_with_fake_connection, +) + +_STATEMENTS: tuple[str, ...] = ("SELECT 1", "SELECT 1 / 0") + + +@pytest.mark.parametrize( + "test_case", + ( + BatchRecordingTestCase( + description="a batch without errors runs as one request", + statements=_STATEMENTS, + batch_error=None, + statement_errors=(), + expected_outcomes=("rows", "rows"), + expected_single_runs=(), + expected_files_written=4, + ), + BatchRecordingTestCase( + description="a statement error wrapped by the multi-statement request reruns the batch " + "one case at a time and records the real error", + statements=_STATEMENTS, + batch_error=(100132, "P0000"), + statement_errors=(("SELECT 1 / 0", 100051, "22012"),), + expected_outcomes=("rows", "error"), + expected_single_runs=_STATEMENTS, + expected_files_written=3, + ), + BatchRecordingTestCase( + description="a wrapped error whose real error is not a case outcome stops the run " + "without writing anything", + statements=_STATEMENTS, + batch_error=(100132, "P0000"), + statement_errors=(("SELECT 1 / 0", 390114, "08001"),), + expected_outcomes=None, + expected_single_runs=_STATEMENTS, + expected_files_written=0, + ), + BatchRecordingTestCase( + description="a batch error that is not a case outcome stops the run at once", + statements=_STATEMENTS, + batch_error=(390114, "08001"), + statement_errors=(), + expected_outcomes=None, + expected_single_runs=(), + expected_files_written=0, + ), + ), + ids=lambda case: case.description, +) +def test_given_batch_errors_when_recording_then_wrapped_errors_rerun_cases_individually( + test_case: BatchRecordingTestCase, tmp_path: Path +) -> None: + outcomes, single_runs, files_written = record_with_fake_connection( + directory=tmp_path, + statements=test_case.statements, + batch_error=test_case.batch_error, + statement_errors=test_case.statement_errors, + ) + + assert outcomes == test_case.expected_outcomes + assert single_runs == test_case.expected_single_runs + assert files_written == test_case.expected_files_written From 22a32e65864c5afc95586d183452c3442cf38a5f Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:39:04 +0100 Subject: [PATCH 09/11] fix: install the connector's pandas extra for Arrow fetches and pin pyarrow 23 --- recorder/pyproject.toml | 4 +- uv.lock | 222 ++++++++++++++++++++++++++++++++-------- 2 files changed, 182 insertions(+), 44 deletions(-) diff --git a/recorder/pyproject.toml b/recorder/pyproject.toml index 60cb3b725..6a135e11f 100644 --- a/recorder/pyproject.toml +++ b/recorder/pyproject.toml @@ -5,8 +5,8 @@ description = "Records Snowflake results for SQLQuack's pending cases as Arrow I requires-python = ">=3.12" license = "Apache-2.0" dependencies = [ - "pyarrow==24.0.0", - "snowflake-connector-python[secure-local-storage]==4.7.2", + "pyarrow==23.0.1", + "snowflake-connector-python[pandas,secure-local-storage]==4.7.2", ] [project.scripts] diff --git a/uv.lock b/uv.lock index 9b4b19373..0062531d0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] [manifest] members = [ @@ -484,6 +489,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl", hash = "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0", size = 74405, upload-time = "2026-10-14T18:50:29.116Z" }, ] +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, + { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, + { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, + { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, + { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -493,6 +571,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + [[package]] name = "platformdirs" version = "4.13.5" @@ -513,45 +638,45 @@ wheels = [ [[package]] name = "pyarrow" -version = "24.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, - { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, - { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, - { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, - { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, - { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, - { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, - { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, - { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, - { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, - { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, - { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, - { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, +version = "23.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, + { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, + { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, + { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, + { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, + { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, + { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] [[package]] @@ -770,6 +895,10 @@ wheels = [ ] [package.optional-dependencies] +pandas = [ + { name = "pandas" }, + { name = "pyarrow" }, +] secure-local-storage = [ { name = "keyring" }, ] @@ -789,13 +918,13 @@ version = "0.1.0" source = { editable = "recorder" } dependencies = [ { name = "pyarrow" }, - { name = "snowflake-connector-python", extra = ["secure-local-storage"] }, + { name = "snowflake-connector-python", extra = ["pandas", "secure-local-storage"] }, ] [package.metadata] requires-dist = [ - { name = "pyarrow", specifier = "==24.0.0" }, - { name = "snowflake-connector-python", extras = ["secure-local-storage"], specifier = "==4.7.2" }, + { name = "pyarrow", specifier = "==23.0.1" }, + { name = "snowflake-connector-python", extras = ["pandas", "secure-local-storage"], specifier = "==4.7.2" }, ] [[package]] @@ -870,6 +999,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + [[package]] name = "urllib3" version = "2.8.0" From ac5a2adca3a18abe326b428280c659adc7782190 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:40:28 +0100 Subject: [PATCH 10/11] fix: store only a hash of the query ID and no warehouse error messages --- .../sqlquack-evidence/src/results/models.rs | 6 +-- .../src/verify/_helpers/judge.rs | 15 ++++--- .../src/verify/tests/helpers.rs | 2 +- docs/architecture.md | 18 ++++++--- .../recording/_helpers/results.py | 5 ++- .../sqlquack_record/recording/constants.py | 1 + .../src/sqlquack_record/recording/models.py | 3 +- .../snowflake/_helpers/cursors.py | 3 +- .../snowflake/classes/snowflake_session.py | 4 +- scripts/evidence_guard/_helpers/metadata.py | 11 +++++ scripts/evidence_guard/constants.py | 3 +- .../sqlquack_record/recording/_test_types.py | 7 ++-- .../src/sqlquack_record/recording/helpers.py | 10 +++-- .../recording/test_record_pending.py | 6 +-- .../snowflake/test_snowflake_session.py | 9 +++++ tests/unit/scripts/evidence_guard/helpers.py | 40 +++++++++++++++++-- .../unit/scripts/evidence_guard/test_check.py | 14 +++++++ 17 files changed, 119 insertions(+), 38 deletions(-) diff --git a/crates/sqlquack-evidence/src/results/models.rs b/crates/sqlquack-evidence/src/results/models.rs index a98bb1ccd..2cc1d73a0 100644 --- a/crates/sqlquack-evidence/src/results/models.rs +++ b/crates/sqlquack-evidence/src/results/models.rs @@ -20,8 +20,8 @@ pub struct ResultMetadata { pub recorder: String, /// The warehouse client name and version. pub connector: String, - /// The warehouse query ID. - pub query_id: Option, + /// SHA-256 of the warehouse query ID, as lower-case hex; the ID itself is never stored. + pub query_id_sha256: Option, /// The warehouse version. pub warehouse_version: String, /// The DuckDB version the case was prepared against. @@ -78,8 +78,6 @@ pub struct RecordedFailure { pub errno: Option, /// The SQLSTATE. pub sqlstate: Option, - /// The message. - pub message: String, } /// A loaded, integrity-checked result. diff --git a/crates/sqlquack-evidence/src/verify/_helpers/judge.rs b/crates/sqlquack-evidence/src/verify/_helpers/judge.rs index 0f98044ad..b9028674c 100644 --- a/crates/sqlquack-evidence/src/verify/_helpers/judge.rs +++ b/crates/sqlquack-evidence/src/verify/_helpers/judge.rs @@ -57,12 +57,15 @@ fn replayed_verdict( (None, Err(_)) => Verdict::Passed, (None, Ok(_)) => Verdict::Failed { reason: format!( - "Snowflake raised an error ({}), but DuckDB returned rows", - stored - .metadata - .error - .as_ref() - .map_or("unknown", |error| error.message.as_str()) + "Snowflake raised error {}, but DuckDB returned rows", + stored.metadata.error.as_ref().map_or_else( + || "unknown".to_owned(), + |error| format!( + "{} (SQLSTATE {})", + error.errno.unwrap_or_default(), + error.sqlstate.as_deref().unwrap_or("unknown") + ) + ) ), }, (Some(_), Err(error)) => Verdict::Failed { diff --git a/crates/sqlquack-evidence/src/verify/tests/helpers.rs b/crates/sqlquack-evidence/src/verify/tests/helpers.rs index 8b4a6e228..e649a346b 100644 --- a/crates/sqlquack-evidence/src/verify/tests/helpers.rs +++ b/crates/sqlquack-evidence/src/verify/tests/helpers.rs @@ -180,7 +180,7 @@ fn record_stand_in(pending: &PendingCase, results: &Path) { outcome: RecordedOutcome::Rows, recorder: STAND_IN_RECORDER.to_owned(), connector: STAND_IN_RECORDER.to_owned(), - query_id: None, + query_id_sha256: None, warehouse_version: STAND_IN_RECORDER.to_owned(), duckdb_version: pending.duckdb_version.clone(), recorded_at: "1970-01-01T00:00:00Z".to_owned(), diff --git a/docs/architecture.md b/docs/architecture.md index 445cbe255..d65a342fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,10 +125,11 @@ it becomes a new case with a new result. 1. `sqlquack pending` writes `evidence/pending/.json` for every case without a result. 2. The maintainer runs `sqlquack-record`, which runs the pending SQL in batches, one Snowflake session per set of session parameters, and writes `evidence/results/.arrow` (the - connector's Arrow result, unconverted) and `.json` (query ID, warehouse, connector and - DuckDB versions, recording time, column descriptions, row count and the Arrow file's SHA-256). - A SQL error is recorded as the outcome instead; session and connection errors stop the run - without writing anything. When any statement of a batch fails, Snowflake reports the whole + connector's Arrow result, unconverted) and `.json` (the SHA-256 of the query ID, + warehouse, connector and DuckDB versions, recording time, column descriptions, row count and + the Arrow file's SHA-256). A SQL error in SQLSTATE class 0A, 21, 22 or 42 is recorded as the + outcome instead, as its error number and SQLSTATE only; session, connection and privilege + (42501) errors stop the run without writing anything. When any statement of a batch fails, Snowflake reports the whole request as error 100132 (SQLSTATE P0000), so the batch's cases are rerun one at a time and each real error is classified on its own. Existing results are never overwritten. 3. `sqlquack verify` converts each recorded case, replays it on DuckDB in a fresh in-memory @@ -149,6 +150,13 @@ Audits compare two recordings and allow no tolerance at all. ### Integrity +Results are public, so they hold nothing specific to the recording account. The raw query ID is +replaced by its SHA-256, which the maintainer can match against query history by hashing. +Snowflake error messages can name databases, schemas, roles and other objects, so they are not +stored. Column names, session parameters and values all come from the case itself, and the +warehouse version is Snowflake's release number. + CI runs `make evidence-guard`: any modified or deleted file under `evidence/results` fails, and an added result must be named by a case hash, have exactly the recorder's metadata keys, name -`sqlquack-record` as its recorder and carry an Arrow file matching its hash. +`sqlquack-record` as its recorder and carry an Arrow file matching its hash. Its query ID must be +a SHA-256 digest, and an error may hold only `errno` and `sqlstate`. diff --git a/recorder/src/sqlquack_record/recording/_helpers/results.py b/recorder/src/sqlquack_record/recording/_helpers/results.py index 9d5ffa235..ce2ada842 100644 --- a/recorder/src/sqlquack_record/recording/_helpers/results.py +++ b/recorder/src/sqlquack_record/recording/_helpers/results.py @@ -37,7 +37,9 @@ def write_result( "outcome": ROWS_OUTCOME if result.table is not None else ERROR_OUTCOME, "recorder": f"{RECORDER_NAME} {RECORDER_VERSION}", "connector": context.connector, - "query_id": result.query_id, + "query_id_sha256": None + if result.query_id is None + else hashlib.sha256(result.query_id.encode("utf-8")).hexdigest(), "warehouse_version": context.warehouse_version, "duckdb_version": case.duckdb_version, "recorded_at": context.recorded_at, @@ -61,7 +63,6 @@ def write_result( else { "errno": result.failure.errno, "sqlstate": result.failure.sqlstate, - "message": result.failure.message, }, } _create_exclusively( diff --git a/recorder/src/sqlquack_record/recording/constants.py b/recorder/src/sqlquack_record/recording/constants.py index 705fb955e..082a76fc7 100644 --- a/recorder/src/sqlquack_record/recording/constants.py +++ b/recorder/src/sqlquack_record/recording/constants.py @@ -19,6 +19,7 @@ "SNOWFLAKE_HOST": "host", } RECORDABLE_SQLSTATE_CLASSES: frozenset[str] = frozenset({"0A", "21", "22", "42"}) +SESSION_DEPENDENT_SQLSTATES: frozenset[str] = frozenset({"42501"}) HASH_NAME_LENGTH: int = 64 HEX_DIGITS: frozenset[str] = frozenset("0123456789abcdef") PENDING_KEYS: frozenset[str] = frozenset( diff --git a/recorder/src/sqlquack_record/recording/models.py b/recorder/src/sqlquack_record/recording/models.py index a4e276d89..d8c0c91db 100644 --- a/recorder/src/sqlquack_record/recording/models.py +++ b/recorder/src/sqlquack_record/recording/models.py @@ -34,11 +34,10 @@ class ColumnDescription: @dataclass(frozen=True) class WarehouseFailure: - """A warehouse SQL error recorded as a case outcome.""" + """A warehouse SQL error recorded as a case outcome; messages can name account objects.""" errno: int | None sqlstate: str | None - message: str @dataclass(frozen=True) diff --git a/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py b/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py index 4d7138284..565cbdcee 100644 --- a/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py +++ b/recorder/src/sqlquack_record/snowflake/_helpers/cursors.py @@ -10,6 +10,7 @@ from sqlquack_record.recording.constants import ( MULTI_STATEMENT_FAILURE, RECORDABLE_SQLSTATE_CLASSES, + SESSION_DEPENDENT_SQLSTATES, ) from sqlquack_record.recording.exceptions import RecorderError from sqlquack_record.recording.models import ColumnDescription, StatementResult @@ -28,7 +29,7 @@ def fetch(cursor: Any) -> StatementResult: def require_recordable(error: ProgrammingError) -> None: """Raise unless `error` is a SQL error that belongs to the case rather than the session.""" sqlstate: str = str(error.sqlstate or "") - if sqlstate[:2] not in RECORDABLE_SQLSTATE_CLASSES: + if sqlstate[:2] not in RECORDABLE_SQLSTATE_CLASSES or sqlstate in SESSION_DEPENDENT_SQLSTATES: raise RecorderError( f"Snowflake error {error.errno} (SQLSTATE {sqlstate or 'unknown'}) is not a case " f"outcome; nothing was recorded: {error.msg}" diff --git a/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py b/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py index 8863c33f5..0c4e2832c 100644 --- a/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py +++ b/recorder/src/sqlquack_record/snowflake/classes/snowflake_session.py @@ -66,9 +66,7 @@ def run_one(self, statement: str) -> StatementResult: query_id=error.sfqid, columns=(), table=None, - failure=WarehouseFailure( - errno=error.errno, sqlstate=error.sqlstate, message=str(error.msg) - ), + failure=WarehouseFailure(errno=error.errno, sqlstate=error.sqlstate), ) finally: cursor.close() diff --git a/scripts/evidence_guard/_helpers/metadata.py b/scripts/evidence_guard/_helpers/metadata.py index 4ac4ebcee..6e2f016ec 100644 --- a/scripts/evidence_guard/_helpers/metadata.py +++ b/scripts/evidence_guard/_helpers/metadata.py @@ -9,6 +9,7 @@ from scripts.evidence_guard._helpers.git import blob from scripts.evidence_guard.constants import ( ARROW_SUFFIX, + ERROR_KEYS, ERROR_OUTCOME, EVIDENCE_FORMAT, HASH_LENGTH, @@ -47,6 +48,16 @@ def _metadata_problems(*, repository: Path, head: str, path: str, stem: str) -> problems: list[str] = [] if metadata["format"] != EVIDENCE_FORMAT or metadata["case_hash"] != stem: problems.append(f"{path}: wrong format or case hash") + query_hash: object = metadata["query_id_sha256"] + if query_hash is not None and ( + not isinstance(query_hash, str) + or len(query_hash) != HASH_LENGTH + or not set(query_hash) <= HEX_DIGITS + ): + problems.append(f"{path}: query_id_sha256 must be a SHA-256 hex digest or null") + error: object = metadata["error"] + if error is not None and (not isinstance(error, dict) or set(error) != ERROR_KEYS): + problems.append(f"{path}: an error records only errno and sqlstate") if not str(metadata["recorder"]).startswith(RECORDER_PREFIX): problems.append(f"{path}: not written by sqlquack-record") arrow: bytes | None = blob( diff --git a/scripts/evidence_guard/constants.py b/scripts/evidence_guard/constants.py index be0e18fee..f99ad56d7 100644 --- a/scripts/evidence_guard/constants.py +++ b/scripts/evidence_guard/constants.py @@ -13,7 +13,7 @@ "outcome", "recorder", "connector", - "query_id", + "query_id_sha256", "warehouse_version", "duckdb_version", "recorded_at", @@ -25,6 +25,7 @@ } ) HASH_LENGTH: int = 64 +ERROR_KEYS: frozenset[str] = frozenset({"errno", "sqlstate"}) ADDED_STATUS: str = "A" ARROW_SUFFIX: str = ".arrow" METADATA_SUFFIX: str = ".json" diff --git a/tests/unit/recorder/src/sqlquack_record/recording/_test_types.py b/tests/unit/recorder/src/sqlquack_record/recording/_test_types.py index 3119b0d41..9e73abd20 100644 --- a/tests/unit/recorder/src/sqlquack_record/recording/_test_types.py +++ b/tests/unit/recorder/src/sqlquack_record/recording/_test_types.py @@ -4,9 +4,10 @@ from dataclasses import dataclass -# What was stored for one case: the outcome, whether the Arrow file matches the metadata hash, -# and whether the stored rows equal the warehouse's Arrow table. -type RecordedFile = tuple[str, bool, bool] +# What was stored for one case: the outcome, whether only a hash of the query ID is stored, +# whether the Arrow file matches the metadata hash, and whether the stored rows equal the +# warehouse's Arrow table. +type RecordedFile = tuple[str, bool, bool, bool] @dataclass(frozen=True) diff --git a/tests/unit/recorder/src/sqlquack_record/recording/helpers.py b/tests/unit/recorder/src/sqlquack_record/recording/helpers.py index c4abe43fb..e666cb46b 100644 --- a/tests/unit/recorder/src/sqlquack_record/recording/helpers.py +++ b/tests/unit/recorder/src/sqlquack_record/recording/helpers.py @@ -29,7 +29,7 @@ query_id="fake-error-query", columns=(), table=None, - failure=WarehouseFailure(errno=100051, sqlstate="22012", message="Division by zero"), + failure=WarehouseFailure(errno=100051, sqlstate="22012"), ) @@ -96,15 +96,17 @@ def _pending_case(*, statement: str, parameters: SessionParameters) -> PendingCa def _recorded_file(*, results: Path, case: PendingCase) -> RecordedFile: - metadata: dict[str, object] = json.loads( - (results / f"{case.case_hash}.json").read_text(encoding="utf-8") - ) + text: str = (results / f"{case.case_hash}.json").read_text(encoding="utf-8") + metadata: dict[str, object] = json.loads(text) arrow_files: list[bytes] = [ path.read_bytes() for path in results.glob(f"{case.case_hash}.arrow") ] claimed: list[object] = [metadata.get("arrow_sha256")] return ( str(metadata.get("outcome", "preexisting")), + "-query" not in text + and "query-" not in text + and len(str(metadata.get("query_id_sha256", ""))) == 64, [hashlib.sha256(data).hexdigest() for data in arrow_files] == list(filter(None, claimed)), all( pa.ipc.open_file(pa.BufferReader(data)).read_all().equals(fake_table(case.source_sql)) diff --git a/tests/unit/recorder/src/sqlquack_record/recording/test_record_pending.py b/tests/unit/recorder/src/sqlquack_record/recording/test_record_pending.py index a60449ea1..e94b38bd6 100644 --- a/tests/unit/recorder/src/sqlquack_record/recording/test_record_pending.py +++ b/tests/unit/recorder/src/sqlquack_record/recording/test_record_pending.py @@ -14,9 +14,9 @@ _UTC: tuple[tuple[str, bool | int | str], ...] = (("TIMEZONE", "UTC"),) _WEEKS: tuple[tuple[str, bool | int | str], ...] = (("TIMEZONE", "UTC"), ("WEEK_START", 1)) -_ROWS: RecordedFile = ("rows", True, True) -_ERROR: RecordedFile = ("error", True, True) -_EXISTING: RecordedFile = ("preexisting", True, True) +_ROWS: RecordedFile = ("rows", True, True, True) +_ERROR: RecordedFile = ("error", True, True, True) +_EXISTING: RecordedFile = ("preexisting", False, True, True) @pytest.mark.parametrize( diff --git a/tests/unit/recorder/src/sqlquack_record/snowflake/test_snowflake_session.py b/tests/unit/recorder/src/sqlquack_record/snowflake/test_snowflake_session.py index c6c27e59f..6a3edcaa2 100644 --- a/tests/unit/recorder/src/sqlquack_record/snowflake/test_snowflake_session.py +++ b/tests/unit/recorder/src/sqlquack_record/snowflake/test_snowflake_session.py @@ -48,6 +48,15 @@ expected_single_runs=_STATEMENTS, expected_files_written=0, ), + BatchRecordingTestCase( + description="a privilege error depends on the role, so it stops the run", + statements=_STATEMENTS, + batch_error=(100132, "P0000"), + statement_errors=(("SELECT 1 / 0", 3001, "42501"),), + expected_outcomes=None, + expected_single_runs=_STATEMENTS, + expected_files_written=0, + ), BatchRecordingTestCase( description="a batch error that is not a case outcome stops the run at once", statements=_STATEMENTS, diff --git a/tests/unit/scripts/evidence_guard/helpers.py b/tests/unit/scripts/evidence_guard/helpers.py index 80c00cf79..24ed5d258 100644 --- a/tests/unit/scripts/evidence_guard/helpers.py +++ b/tests/unit/scripts/evidence_guard/helpers.py @@ -23,7 +23,15 @@ def _git(repository: Path, *arguments: str) -> None: ) -def _write_result(*, results: Path, seed: str, recorder: str, arrow: bytes, claimed: bytes) -> str: +def _write_result( + *, + results: Path, + seed: str, + recorder: str, + arrow: bytes, + claimed: bytes, + replaced: tuple[tuple[str, object], ...] = (), +) -> str: case_hash: str = hashlib.sha256(seed.encode("utf-8")).hexdigest() (results / f"{case_hash}.arrow").write_bytes(arrow) metadata: dict[str, object] = { @@ -32,7 +40,7 @@ def _write_result(*, results: Path, seed: str, recorder: str, arrow: bytes, clai "outcome": "rows", "recorder": recorder, "connector": "test", - "query_id": None, + "query_id_sha256": hashlib.sha256(b"01b2-fake-query").hexdigest(), "warehouse_version": "test", "duckdb_version": "v2.0.0", "recorded_at": "1970-01-01T00:00:00Z", @@ -42,7 +50,9 @@ def _write_result(*, results: Path, seed: str, recorder: str, arrow: bytes, clai "arrow_sha256": hashlib.sha256(claimed).hexdigest(), "error": None, } - (results / f"{case_hash}.json").write_text(json.dumps(metadata), encoding="utf-8") + (results / f"{case_hash}.json").write_text( + json.dumps({**metadata, **dict(replaced)}), encoding="utf-8" + ) return case_hash @@ -77,6 +87,30 @@ def add_stand_in(results: Path, _: str) -> None: ) +def add_raw_query_id(results: Path, _: str) -> None: + """Adds a result that stores a raw query ID instead of its hash.""" + _write_result( + results=results, + seed="new", + recorder=_RECORDER, + arrow=b"new", + claimed=b"new", + replaced=(("query_id_sha256", "01b2-fake-query"),), + ) + + +def add_error_message(results: Path, _: str) -> None: + """Adds an error result that keeps the warehouse's message.""" + _write_result( + results=results, + seed="new", + recorder=_RECORDER, + arrow=b"new", + claimed=b"new", + replaced=(("error", {"errno": 2003, "sqlstate": "42S02", "message": "Object missing"}),), + ) + + def guard_after(*, repository: Path, change: Change) -> int: """Commit one recorded result as the base, apply `change` on top, and run the guard.""" results: Path = repository / _RESULTS diff --git a/tests/unit/scripts/evidence_guard/test_check.py b/tests/unit/scripts/evidence_guard/test_check.py index 58e295b56..07e0bcb40 100644 --- a/tests/unit/scripts/evidence_guard/test_check.py +++ b/tests/unit/scripts/evidence_guard/test_check.py @@ -8,7 +8,9 @@ from tests.unit.scripts.evidence_guard._test_types import GuardTestCase from tests.unit.scripts.evidence_guard.helpers import ( + add_error_message, add_mismatched, + add_raw_query_id, add_stand_in, add_valid, delete_stored, @@ -51,6 +53,18 @@ expected_return_code=1, expected_fragment="does not match arrow_sha256", ), + GuardTestCase( + description="a raw query ID is rejected", + change=add_raw_query_id, + expected_return_code=1, + expected_fragment="query_id_sha256 must be a SHA-256 hex digest", + ), + GuardTestCase( + description="an error message is rejected", + change=add_error_message, + expected_return_code=1, + expected_fragment="an error records only errno and sqlstate", + ), GuardTestCase( description="a result not written by the recorder is rejected", change=add_stand_in, From e29332b8f7b3660d935f9fb3597fae0405870a42 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:40:56 +0100 Subject: [PATCH 11/11] evidence: first Snowflake recording of the seed cases --- ...5fd6d9adfa8bb89c811e29472fa3bc2054117.json | 27 ++++++++++++++ ...f24779fb5d0b5cd51fb446fa10498b8a0a0c.arrow | Bin 0 -> 530 bytes ...df24779fb5d0b5cd51fb446fa10498b8a0a0c.json | 34 ++++++++++++++++++ ...c1639fcb6df341820153797d312f8b110ed3.arrow | Bin 0 -> 1074 bytes ...bc1639fcb6df341820153797d312f8b110ed3.json | 34 ++++++++++++++++++ ...b4538ea79b1caecf60435c43a3e68bf7a354.arrow | Bin 0 -> 1074 bytes ...4b4538ea79b1caecf60435c43a3e68bf7a354.json | 34 ++++++++++++++++++ ...bb8ad4891aebaffca0474648b7b3202442f3.arrow | Bin 0 -> 1018 bytes ...2bb8ad4891aebaffca0474648b7b3202442f3.json | 34 ++++++++++++++++++ ...51ae850617599326dfcf897c95484ac2afce.arrow | Bin 0 -> 1018 bytes ...d51ae850617599326dfcf897c95484ac2afce.json | 34 ++++++++++++++++++ ...0678d3361ba2232849dd95dcfc05045972f6.arrow | Bin 0 -> 562 bytes ...50678d3361ba2232849dd95dcfc05045972f6.json | 34 ++++++++++++++++++ ...8f65ab497f394536ff274bba059d7e92a4ab.arrow | Bin 0 -> 1018 bytes ...e8f65ab497f394536ff274bba059d7e92a4ab.json | 34 ++++++++++++++++++ ...45a964d36254a33cae4186188046d58984b8.arrow | Bin 0 -> 1074 bytes ...b45a964d36254a33cae4186188046d58984b8.json | 34 ++++++++++++++++++ 17 files changed, 299 insertions(+) create mode 100644 evidence/results/00d4243cdcef5e74d0995781f475fd6d9adfa8bb89c811e29472fa3bc2054117.json create mode 100644 evidence/results/480d1c06631d5effb5f72fd1eeddf24779fb5d0b5cd51fb446fa10498b8a0a0c.arrow create mode 100644 evidence/results/480d1c06631d5effb5f72fd1eeddf24779fb5d0b5cd51fb446fa10498b8a0a0c.json create mode 100644 evidence/results/5443a3e458a775c36b9e2a02eddbc1639fcb6df341820153797d312f8b110ed3.arrow create mode 100644 evidence/results/5443a3e458a775c36b9e2a02eddbc1639fcb6df341820153797d312f8b110ed3.json create mode 100644 evidence/results/60057adf149d6030c9f4b8c2a2a4b4538ea79b1caecf60435c43a3e68bf7a354.arrow create mode 100644 evidence/results/60057adf149d6030c9f4b8c2a2a4b4538ea79b1caecf60435c43a3e68bf7a354.json create mode 100644 evidence/results/670861a79c431d94f474559419f2bb8ad4891aebaffca0474648b7b3202442f3.arrow create mode 100644 evidence/results/670861a79c431d94f474559419f2bb8ad4891aebaffca0474648b7b3202442f3.json create mode 100644 evidence/results/76d816ea088b4f13b5fc0a07326d51ae850617599326dfcf897c95484ac2afce.arrow create mode 100644 evidence/results/76d816ea088b4f13b5fc0a07326d51ae850617599326dfcf897c95484ac2afce.json create mode 100644 evidence/results/8094c7235054f31b76d28fee75950678d3361ba2232849dd95dcfc05045972f6.arrow create mode 100644 evidence/results/8094c7235054f31b76d28fee75950678d3361ba2232849dd95dcfc05045972f6.json create mode 100644 evidence/results/da92f96d7ca79051f762f88b340e8f65ab497f394536ff274bba059d7e92a4ab.arrow create mode 100644 evidence/results/da92f96d7ca79051f762f88b340e8f65ab497f394536ff274bba059d7e92a4ab.json create mode 100644 evidence/results/e957c1ca67658aab146698e1d1bb45a964d36254a33cae4186188046d58984b8.arrow create mode 100644 evidence/results/e957c1ca67658aab146698e1d1bb45a964d36254a33cae4186188046d58984b8.json diff --git a/evidence/results/00d4243cdcef5e74d0995781f475fd6d9adfa8bb89c811e29472fa3bc2054117.json b/evidence/results/00d4243cdcef5e74d0995781f475fd6d9adfa8bb89c811e29472fa3bc2054117.json new file mode 100644 index 000000000..42b1725bd --- /dev/null +++ b/evidence/results/00d4243cdcef5e74d0995781f475fd6d9adfa8bb89c811e29472fa3bc2054117.json @@ -0,0 +1,27 @@ +{ + "arrow_sha256": null, + "case_hash": "00d4243cdcef5e74d0995781f475fd6d9adfa8bb89c811e29472fa3bc2054117", + "columns": [], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "error": { + "errno": 100051, + "sqlstate": "22012" + }, + "format": 1, + "outcome": "error", + "query_id_sha256": "1c65b71feac7c610501f0bd70615b073ea553e0aac44f681bd06d49e5bc641fd", + "recorded_at": "2026-10-24T08:40:47Z", + "recorder": "sqlquack-record 0.1.0", + "row_count": null, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.38.101" +} diff --git a/evidence/results/480d1c06631d5effb5f72fd1eeddf24779fb5d0b5cd51fb446fa10498b8a0a0c.arrow b/evidence/results/480d1c06631d5effb5f72fd1eeddf24779fb5d0b5cd51fb446fa10498b8a0a0c.arrow new file mode 100644 index 0000000000000000000000000000000000000000..3ba6c8d7d861b556fe74945a0b05e4e99929b03f GIT binary patch literal 530 zcmcJNI}XAy5JWd5Ay(wV1wxcmh!Q#qniOy#G>S%AOISgnhL`Sm zKHwd-Qj+2d<{Hg$Czr0^Z9Yumqq>!qEjZuBTbY(TX=&CyRNoS2OmcwN%!2v~o8+R^ zUH&fD<@`M^r0%IfM;OV(P*(~xi)Wi@+&H8i*jDqeq4rj<2NbgZ?U&B~{qJA2>Ec`X E0^|-W3;+NC literal 0 HcmV?d00001 diff --git a/evidence/results/480d1c06631d5effb5f72fd1eeddf24779fb5d0b5cd51fb446fa10498b8a0a0c.json b/evidence/results/480d1c06631d5effb5f72fd1eeddf24779fb5d0b5cd51fb446fa10498b8a0a0c.json new file mode 100644 index 000000000..d727b2a15 --- /dev/null +++ b/evidence/results/480d1c06631d5effb5f72fd1eeddf24779fb5d0b5cd51fb446fa10498b8a0a0c.json @@ -0,0 +1,34 @@ +{ + "arrow_sha256": "3733e41767244877c9445b421c30880ffe506f7c09bcb304b3ff347c31d0945a", + "case_hash": "480d1c06631d5effb5f72fd1eeddf24779fb5d0b5cd51fb446fa10498b8a0a0c", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": 13, + "scale": 4, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "error": null, + "format": 1, + "outcome": "rows", + "query_id_sha256": "4a4f460dfeee47af4db62af5bf7017b524db307f608720491e4e51cb5af8bb59", + "recorded_at": "2026-10-24T08:40:47Z", + "recorder": "sqlquack-record 0.1.0", + "row_count": 3, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.38.101" +} diff --git a/evidence/results/5443a3e458a775c36b9e2a02eddbc1639fcb6df341820153797d312f8b110ed3.arrow b/evidence/results/5443a3e458a775c36b9e2a02eddbc1639fcb6df341820153797d312f8b110ed3.arrow new file mode 100644 index 0000000000000000000000000000000000000000..f180306ce9f8753df453f313181d3e7178acdfc3 GIT binary patch literal 1074 zcmeHHyH3ME5L^@6nBXFU2!+liMGBlCg_0EMA|XZQQP3FUIF_s=ax9_z0R;tRN?N*n z03E+U!JmYgvrixzIx0q*+ugg}+lNo?k4EQLzQ_i6Fhm+c)WsEBEODR#h6!6>IatkV z#Wt)KD6$9XAYxnjHKd6h!myfH+i+wgb;1HK`@@R?qz!(6Yw!`g0o$Mno}nhzL!G$- za0eWx(=ZE`^BDRad{_g0c;H=2aG_I>@4`xtkz z?nM-4%=`ZBd<%9L37LPM$5FaSXH)17ron0H-dQ6V46fOV?1I@WNpYp>#>6Sl30%Kf zLF$>ga!`E{SIt;zrkXwbEK!GD=40POkh5dW8a1sza-IZhBU+^puaf(fq@b_9hrJSq zrBEyH(BwN|Y?6+P`#hYJo)?AVw-@EhzNoM^7v0&2bYU@$(_ueHN-OiOszRT!M||~L R+W&9zZ{Fk@-=VMm6`#`7h2a1I literal 0 HcmV?d00001 diff --git a/evidence/results/5443a3e458a775c36b9e2a02eddbc1639fcb6df341820153797d312f8b110ed3.json b/evidence/results/5443a3e458a775c36b9e2a02eddbc1639fcb6df341820153797d312f8b110ed3.json new file mode 100644 index 000000000..3e6160448 --- /dev/null +++ b/evidence/results/5443a3e458a775c36b9e2a02eddbc1639fcb6df341820153797d312f8b110ed3.json @@ -0,0 +1,34 @@ +{ + "arrow_sha256": "0b006ba61dcb42aba1d46e08465fa2ceb5b71fd29f82b9d445257ba1a0b7fe51", + "case_hash": "5443a3e458a775c36b9e2a02eddbc1639fcb6df341820153797d312f8b110ed3", + "columns": [ + { + "internal_size": 134217728, + "name": "VALUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "error": null, + "format": 1, + "outcome": "rows", + "query_id_sha256": "99db5548b008e2f2286e894b4d88eae9b30778edaa9535b28ffea2119227c502", + "recorded_at": "2026-10-24T08:40:47Z", + "recorder": "sqlquack-record 0.1.0", + "row_count": 5, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.38.101" +} diff --git a/evidence/results/60057adf149d6030c9f4b8c2a2a4b4538ea79b1caecf60435c43a3e68bf7a354.arrow b/evidence/results/60057adf149d6030c9f4b8c2a2a4b4538ea79b1caecf60435c43a3e68bf7a354.arrow new file mode 100644 index 0000000000000000000000000000000000000000..f180306ce9f8753df453f313181d3e7178acdfc3 GIT binary patch literal 1074 zcmeHHyH3ME5L^@6nBXFU2!+liMGBlCg_0EMA|XZQQP3FUIF_s=ax9_z0R;tRN?N*n z03E+U!JmYgvrixzIx0q*+ugg}+lNo?k4EQLzQ_i6Fhm+c)WsEBEODR#h6!6>IatkV z#Wt)KD6$9XAYxnjHKd6h!myfH+i+wgb;1HK`@@R?qz!(6Yw!`g0o$Mno}nhzL!G$- za0eWx(=ZE`^BDRad{_g0c;H=2aG_I>@4`xtkz z?nM-4%=`ZBd<%9L37LPM$5FaSXH)17ron0H-dQ6V46fOV?1I@WNpYp>#>6Sl30%Kf zLF$>ga!`E{SIt;zrkXwbEK!GD=40POkh5dW8a1sza-IZhBU+^puaf(fq@b_9hrJSq zrBEyH(BwN|Y?6+P`#hYJo)?AVw-@EhzNoM^7v0&2bYU@$(_ueHN-OiOszRT!M||~L R+W&9zZ{Fk@-=VMm6`#`7h2a1I literal 0 HcmV?d00001 diff --git a/evidence/results/60057adf149d6030c9f4b8c2a2a4b4538ea79b1caecf60435c43a3e68bf7a354.json b/evidence/results/60057adf149d6030c9f4b8c2a2a4b4538ea79b1caecf60435c43a3e68bf7a354.json new file mode 100644 index 000000000..ce29f63e4 --- /dev/null +++ b/evidence/results/60057adf149d6030c9f4b8c2a2a4b4538ea79b1caecf60435c43a3e68bf7a354.json @@ -0,0 +1,34 @@ +{ + "arrow_sha256": "0b006ba61dcb42aba1d46e08465fa2ceb5b71fd29f82b9d445257ba1a0b7fe51", + "case_hash": "60057adf149d6030c9f4b8c2a2a4b4538ea79b1caecf60435c43a3e68bf7a354", + "columns": [ + { + "internal_size": 134217728, + "name": "VALUE", + "nullable": true, + "precision": null, + "scale": null, + "type_code": 2, + "type_name": "TEXT" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "error": null, + "format": 1, + "outcome": "rows", + "query_id_sha256": "a6eecc51278dde1bb288b2be88a88aa560e1816043ac75fca9b9908ca8ea5973", + "recorded_at": "2026-10-24T08:40:47Z", + "recorder": "sqlquack-record 0.1.0", + "row_count": 5, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.38.101" +} diff --git a/evidence/results/670861a79c431d94f474559419f2bb8ad4891aebaffca0474648b7b3202442f3.arrow b/evidence/results/670861a79c431d94f474559419f2bb8ad4891aebaffca0474648b7b3202442f3.arrow new file mode 100644 index 0000000000000000000000000000000000000000..a23fbabdd6ae94f457505a21704af50d9baf683f GIT binary patch literal 1018 zcmeHGK}y3w6#Z$^bgY9EDdGWKxNxDkap5MQLP4ySh`Nj+wte|8Hjg{6C2XgZ|x-$P#$*L^?vWMN246QJ4W<09#-we1rN6 zH>_PyWE-Qxik8uJqzez>HM;ojLCBR>Yv(t&(N)}wAE*k!Pw)+V24nC548UvT#hf_j zd5mp`mOOt}~2@Tqe^j&1d-67 zp=GD;h&DAAKQQ&Q{i31`+n&vyor11zj1tV*fwd&udCb#+F=sp7mJ9m6aiJ7Tx_E|0 rmLYc1u=q~68l{naGqZhL)Em7r46O3D_W#>jz4O0(Tkq&YWd8YYpl^7g literal 0 HcmV?d00001 diff --git a/evidence/results/670861a79c431d94f474559419f2bb8ad4891aebaffca0474648b7b3202442f3.json b/evidence/results/670861a79c431d94f474559419f2bb8ad4891aebaffca0474648b7b3202442f3.json new file mode 100644 index 000000000..49bbcffcc --- /dev/null +++ b/evidence/results/670861a79c431d94f474559419f2bb8ad4891aebaffca0474648b7b3202442f3.json @@ -0,0 +1,34 @@ +{ + "arrow_sha256": "51ef402614cb5fe7bcac0b3ae0f0b97c4f12ddc45e7a9d6cb4abc1442fc0620a", + "case_hash": "670861a79c431d94f474559419f2bb8ad4891aebaffca0474648b7b3202442f3", + "columns": [ + { + "internal_size": null, + "name": "QUANTITY", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "error": null, + "format": 1, + "outcome": "rows", + "query_id_sha256": "8800d084c6a6aeabb76a52d87f2debd799a688c303cad1382348bf0b467404c2", + "recorded_at": "2026-10-24T08:40:47Z", + "recorder": "sqlquack-record 0.1.0", + "row_count": 4, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.38.101" +} diff --git a/evidence/results/76d816ea088b4f13b5fc0a07326d51ae850617599326dfcf897c95484ac2afce.arrow b/evidence/results/76d816ea088b4f13b5fc0a07326d51ae850617599326dfcf897c95484ac2afce.arrow new file mode 100644 index 0000000000000000000000000000000000000000..e4f29129aeeb91aa6ed655ff38e610157b0e80f4 GIT binary patch literal 1018 zcmeHGO-jQ+6n<&aIMzXm6!8EqT)0r&xNs9tp&D{6 z2oK<0>i5lLtQ5EI{NT&HH}9L7H}6fNUhnGeP-FqTcp_~fTB0SCrYKl|7r+;I3g4jq zVjA8KD6)l7Va1x!wWWg@!fSN!-Gz`Vt=8H%x6x(XjUT8A!4Gf_K7%p14+h{h@?uW3 zc^+fy;8R{EMLZp4u&Wgfu4D3koL1RIR`#ob$ZSP(P;1gbGIpBxUo=hVCTUXeoX;Qe z2K=LAo6BgNrTHWumY7qh04v1%^6t!=dz^Rg&rUIJfW@$%V@s1g^$K;P9`NeX_5%~# zgV3^FcSM^SiyxSJ+I~^df^W}e&rU#BH%1BO?7&(Q?mXt{z?k!$Zu2|n`!rFC1xKEt sk!6UTG(5f&u10BO&ser^vwEXfhJj_?*8hK7%Xj{lZ|ew`D{ zotfRll;vhW7kL3E5z(MXCb@JZmJ}8cDfj{}g;ZW546hGr$Q1R7tV`gF70+IyRO-TT)eoAvpZ558y+7)1$nhX;?waGa9s45M-4QRdKzs)B X?YPSYB!d3l-^Ks^ZEhH2?7#8`cgQYY literal 0 HcmV?d00001 diff --git a/evidence/results/8094c7235054f31b76d28fee75950678d3361ba2232849dd95dcfc05045972f6.json b/evidence/results/8094c7235054f31b76d28fee75950678d3361ba2232849dd95dcfc05045972f6.json new file mode 100644 index 000000000..47032ee9d --- /dev/null +++ b/evidence/results/8094c7235054f31b76d28fee75950678d3361ba2232849dd95dcfc05045972f6.json @@ -0,0 +1,34 @@ +{ + "arrow_sha256": "9275ef2afb83304174a68c84bc88a42a66bfa43ed90675c726438ec68c442358", + "case_hash": "8094c7235054f31b76d28fee75950678d3361ba2232849dd95dcfc05045972f6", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": 18, + "scale": 8, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "error": null, + "format": 1, + "outcome": "rows", + "query_id_sha256": "0e8e38ba500bb43ba588745b34fb6adbbf5e4b85f827f001e8a5c5d0ecf4816b", + "recorded_at": "2026-10-24T08:40:47Z", + "recorder": "sqlquack-record 0.1.0", + "row_count": 5, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.38.101" +} diff --git a/evidence/results/da92f96d7ca79051f762f88b340e8f65ab497f394536ff274bba059d7e92a4ab.arrow b/evidence/results/da92f96d7ca79051f762f88b340e8f65ab497f394536ff274bba059d7e92a4ab.arrow new file mode 100644 index 0000000000000000000000000000000000000000..6f4ac2ab2c76b9c1b6661d359f50cf886012ea08 GIT binary patch literal 1018 zcmeHGJ5Iwu5FN*kW5N~@iXsj`K|ukcp`ft@NRbeq$OtHnWfDu46D4jUaR5rnlpKKr z3+PEK5tC^YtU% zfPZ{qa+yr?te6$!3Ud)EzzXrcygT#e9vA(G^D~UwU^yNZ*ivUteT2GE4|sMo{lEnG zAe8iMN0hFy_<^pc=@%7k_~vZ(>=bl$W0YXd4y+~N&SRbqj5*)wS~lqUzP3<`6%C%D skY$LS6g<8Yu10BOUt6Yc3%k)9!@xRk{^*jH|xAlQO#QLBA29vaSng9R* literal 0 HcmV?d00001 diff --git a/evidence/results/da92f96d7ca79051f762f88b340e8f65ab497f394536ff274bba059d7e92a4ab.json b/evidence/results/da92f96d7ca79051f762f88b340e8f65ab497f394536ff274bba059d7e92a4ab.json new file mode 100644 index 000000000..af60bc4e2 --- /dev/null +++ b/evidence/results/da92f96d7ca79051f762f88b340e8f65ab497f394536ff274bba059d7e92a4ab.json @@ -0,0 +1,34 @@ +{ + "arrow_sha256": "e82f80b85fbec3390d85cbd1fdc8c47cbb3883084a42d44f4920ca085998621c", + "case_hash": "da92f96d7ca79051f762f88b340e8f65ab497f394536ff274bba059d7e92a4ab", + "columns": [ + { + "internal_size": null, + "name": "QUANTITY", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "error": null, + "format": 1, + "outcome": "rows", + "query_id_sha256": "004bf2c9cb4440394173cc8ba137cdb16920b5d8a62140da49bb541fc1dec8a9", + "recorded_at": "2026-10-24T08:40:47Z", + "recorder": "sqlquack-record 0.1.0", + "row_count": 4, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.38.101" +} diff --git a/evidence/results/e957c1ca67658aab146698e1d1bb45a964d36254a33cae4186188046d58984b8.arrow b/evidence/results/e957c1ca67658aab146698e1d1bb45a964d36254a33cae4186188046d58984b8.arrow new file mode 100644 index 0000000000000000000000000000000000000000..4d4c5999740750b74a5971c6e427f8e1f2dbf711 GIT binary patch literal 1074 zcmeHHu}T9$5S=rZ?TgLd3IS7nZxc3k&4jDVoO2tV!?wMCSpNk zVKmbbpAoGBB1@PptSBm8MXLBhGm2G=YantZeOey4aNP4c<^u2rJOj5t3)liopaD(P zL!Y&nD*-oQ>_x3{KY%~Va262PcBx+EdOQl;AnuI1B2O6(_t^%>@w?tYb6$SoQ0LJ1 zBG!4&UkEmQRca81>N4diGj zGk?IWdu7XVdK0$lt>jGA^R{AhC?ihl@=ON+d&aujs8<`$PoqX!Bbn65oL;ASD``HL z;nDq|(2RXc{|HGvTz@Q0Uhl7F?*1ublBm_jY1s)I5#Jea9PO1&d-q9ra)F3GLL7RR910 literal 0 HcmV?d00001 diff --git a/evidence/results/e957c1ca67658aab146698e1d1bb45a964d36254a33cae4186188046d58984b8.json b/evidence/results/e957c1ca67658aab146698e1d1bb45a964d36254a33cae4186188046d58984b8.json new file mode 100644 index 000000000..668a9d4c8 --- /dev/null +++ b/evidence/results/e957c1ca67658aab146698e1d1bb45a964d36254a33cae4186188046d58984b8.json @@ -0,0 +1,34 @@ +{ + "arrow_sha256": "f2b85a3296b05817ea961e25d1ea979e588e66420da3471896c16f78cee218f5", + "case_hash": "e957c1ca67658aab146698e1d1bb45a964d36254a33cae4186188046d58984b8", + "columns": [ + { + "internal_size": null, + "name": "VALUE", + "nullable": true, + "precision": 38, + "scale": 0, + "type_code": 0, + "type_name": "FIXED" + } + ], + "connector": "snowflake-connector-python 4.7.2", + "duckdb_version": "v2.0.0", + "error": null, + "format": 1, + "outcome": "rows", + "query_id_sha256": "d62b2a0bc11c0bd82492b75e4360bd3aeb37efe73af8264346140dd236c715e8", + "recorded_at": "2026-10-24T08:40:47Z", + "recorder": "sqlquack-record 0.1.0", + "row_count": 5, + "session_parameters": { + "DEFAULT_NULL_ORDERING": "LAST", + "QUOTED_IDENTIFIERS_IGNORE_CASE": false, + "TIMESTAMP_TYPE_MAPPING": "TIMESTAMP_NTZ", + "TIMEZONE": "UTC", + "USE_CACHED_RESULT": false, + "WEEK_OF_YEAR_POLICY": 0, + "WEEK_START": 0 + }, + "warehouse_version": "10.38.101" +}